Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
// 2. For episodes, try series/season backdrops
|
||||
if (currentItem.type === "Episode") {
|
||||
if (currentItem.kind === "episode") {
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
|
||||
}
|
||||
@@ -45,17 +45,17 @@
|
||||
}
|
||||
|
||||
// 3. For music tracks, try album backdrop
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
if (currentItem.kind === "track" && currentItem.albumId) {
|
||||
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
|
||||
// 4. Fall back to primary image
|
||||
if (currentItem.primaryImageTag) {
|
||||
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
|
||||
if (currentItem.imageId) {
|
||||
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.imageId };
|
||||
}
|
||||
|
||||
// 5. Last resort for audio: album primary
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
if (currentItem.kind === "track" && currentItem.albumId) {
|
||||
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
onclick={() => {
|
||||
// Navigate to full series detail page with cast/crew/related content
|
||||
// (even for episodes, show the series page so users see cast and related items)
|
||||
if (currentItem.type === "Episode" && currentItem.seriesId) {
|
||||
if (currentItem.kind === "episode" && currentItem.seriesId) {
|
||||
goto(`/library/${currentItem.seriesId}`);
|
||||
} else {
|
||||
goto(`/library/${currentItem.id}`);
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
albums = albumsResult.items.filter(item => item.type === "MusicAlbum");
|
||||
albums = albumsResult.items.filter(item => item.kind === "album");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums:", e);
|
||||
} finally {
|
||||
@@ -58,7 +58,7 @@
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
topTracks = tracksResult.items.filter(item => item.type === "Audio");
|
||||
topTracks = tracksResult.items.filter(item => item.kind === "track");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load tracks:", e);
|
||||
} finally {
|
||||
@@ -76,7 +76,7 @@
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
relatedArtists = relatedResult.items
|
||||
.filter(item => item.id !== artist.id && item.type === "MusicArtist")
|
||||
.filter(item => item.id !== artist.id && item.kind === "artist")
|
||||
.slice(0, 6);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -112,12 +112,12 @@
|
||||
<!-- Artist Info -->
|
||||
<div class="flex flex-col items-center text-center py-12">
|
||||
<!-- Artist Image -->
|
||||
{#if artist.primaryImageTag}
|
||||
{#if artist.imageId}
|
||||
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
|
||||
<CachedImage
|
||||
itemId={artist.id}
|
||||
imageType="Primary"
|
||||
tag={artist.primaryImageTag}
|
||||
tag={artist.imageId}
|
||||
maxWidth={400}
|
||||
alt={artist.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -160,11 +160,11 @@
|
||||
class="group cursor-pointer"
|
||||
>
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
|
||||
{#if album.primaryImageTag}
|
||||
{#if album.imageId}
|
||||
<CachedImage
|
||||
itemId={album.id}
|
||||
imageType="Primary"
|
||||
tag={album.primaryImageTag}
|
||||
tag={album.imageId}
|
||||
maxWidth={200}
|
||||
alt={album.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -218,11 +218,11 @@
|
||||
class="group text-center"
|
||||
>
|
||||
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
|
||||
{#if relatedArtist.primaryImageTag}
|
||||
{#if relatedArtist.imageId}
|
||||
<CachedImage
|
||||
itemId={relatedArtist.id}
|
||||
imageType="Primary"
|
||||
tag={relatedArtist.primaryImageTag}
|
||||
tag={relatedArtist.imageId}
|
||||
maxWidth={200}
|
||||
alt={relatedArtist.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
||||
}
|
||||
if (episode.primaryImageTag) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
|
||||
if (episode.imageId) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
||||
}
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||
@@ -86,9 +86,9 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
function formatDuration(ticks?: number | null): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms) return "";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -99,10 +99,10 @@
|
||||
}
|
||||
|
||||
function getProgress(ep: MediaItem): number {
|
||||
if (!ep.userData || !ep.runTimeTicks) {
|
||||
if (!ep.userData || !ep.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
|
||||
return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
@@ -116,7 +116,7 @@
|
||||
const episodeLabel = $derived(
|
||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||
);
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const progress = $derived(getProgress(episode));
|
||||
</script>
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
<CachedImage
|
||||
itemId={ep.id}
|
||||
imageType="Primary"
|
||||
tag={ep.primaryImageTag}
|
||||
tag={ep.imageId}
|
||||
maxWidth={400}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||
|
||||
@@ -38,13 +38,13 @@
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!episode.userData || !episode.runTimeTicks) {
|
||||
if (!episode.userData || !episode.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
|
||||
return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
|
||||
});
|
||||
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||
</script>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<CachedImage
|
||||
itemId={episode.id}
|
||||
imageType="Primary"
|
||||
tag={episode.primaryImageTag}
|
||||
tag={episode.imageId}
|
||||
maxWidth={320}
|
||||
alt={episode.name}
|
||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
tag={item.imageId}
|
||||
maxWidth={300}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import type { MediaKind } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
genres: string[];
|
||||
maxShow?: number; // Default: unlimited
|
||||
clickable?: boolean; // Default: true
|
||||
itemType?: string; // Determines which genre browse page to open
|
||||
itemKind?: MediaKind; // Determines which genre browse page to open
|
||||
}
|
||||
|
||||
let {
|
||||
genres,
|
||||
maxShow,
|
||||
clickable = true,
|
||||
itemType
|
||||
itemKind
|
||||
}: Props = $props();
|
||||
|
||||
// Map the item type to its genre-browse route
|
||||
function genreBasePath(type: string | undefined): string {
|
||||
switch (type) {
|
||||
case "MusicAlbum":
|
||||
case "MusicArtist":
|
||||
case "Audio":
|
||||
// Map the item kind to its genre-browse route
|
||||
function genreBasePath(kind: MediaKind | undefined): string {
|
||||
switch (kind) {
|
||||
case "album":
|
||||
case "artist":
|
||||
case "track":
|
||||
case "playlist":
|
||||
return "/library/music/genres";
|
||||
case "Series":
|
||||
case "Season":
|
||||
case "Episode":
|
||||
case "series":
|
||||
case "season":
|
||||
case "episode":
|
||||
return "/library/shows/genres";
|
||||
case "Movie":
|
||||
case "movie":
|
||||
return "/library/movies/genres";
|
||||
default:
|
||||
return "/library/movies/genres";
|
||||
@@ -43,7 +46,7 @@
|
||||
|
||||
function handleGenreClick(genre: string) {
|
||||
if (clickable) {
|
||||
goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
|
||||
goto(`${genreBasePath(itemKind)}?genre=${encodeURIComponent(genre)}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
|
||||
return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
|
||||
}
|
||||
|
||||
function getSubtitle(item: MediaItem | Library): string {
|
||||
@@ -42,10 +42,10 @@
|
||||
|
||||
|
||||
function getProgress(item: MediaItem | Library): number {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
||||
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
||||
}
|
||||
|
||||
function getTrackNumber(item: MediaItem | Library): string {
|
||||
@@ -59,7 +59,7 @@
|
||||
<div class="space-y-1">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const subtitle = getSubtitle(item)}
|
||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||
{@const duration = "durationMs" in item ? formatDuration(item.durationMs) : ""}
|
||||
{@const progress = getProgress(item)}
|
||||
{@const trackNum = getTrackNumber(item)}
|
||||
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
|
||||
|
||||
@@ -101,11 +101,11 @@
|
||||
};
|
||||
|
||||
const isMusicType = $derived(
|
||||
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist")
|
||||
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
||||
);
|
||||
|
||||
const aspectRatio = $derived(() => {
|
||||
if ("type" in item) {
|
||||
if ("kind" in item) {
|
||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||
}
|
||||
// Library
|
||||
@@ -113,16 +113,16 @@
|
||||
});
|
||||
|
||||
const imageTag = $derived(
|
||||
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
|
||||
"imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined)
|
||||
);
|
||||
|
||||
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
||||
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
||||
});
|
||||
|
||||
const subtitle = $derived(() => {
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
});
|
||||
|
||||
// Separate movies and series
|
||||
movies = result.items.filter(item => item.type === "Movie");
|
||||
series = result.items.filter(item => item.type === "Series");
|
||||
movies = result.items.filter(item => item.kind === "movie");
|
||||
series = result.items.filter(item => item.kind === "series");
|
||||
} catch (e) {
|
||||
console.error("Failed to load filmography:", e);
|
||||
} finally {
|
||||
@@ -53,7 +53,7 @@
|
||||
<CachedImage
|
||||
itemId={person.id}
|
||||
imageType="Primary"
|
||||
tag={person.primaryImageTag}
|
||||
tag={person.imageId}
|
||||
maxWidth={400}
|
||||
alt={person.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
|
||||
|
||||
const totalDuration = $derived(
|
||||
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0)
|
||||
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
@@ -146,11 +146,11 @@
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Playlist artwork -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if playlist.primaryImageTag}
|
||||
{#if playlist.imageId}
|
||||
<CachedImage
|
||||
itemId={playlist.id}
|
||||
imageType="Primary"
|
||||
tag={playlist.primaryImageTag}
|
||||
tag={playlist.imageId}
|
||||
maxWidth={400}
|
||||
alt={playlist.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem, Person } from "$lib/api/types";
|
||||
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
|
||||
interface Props {
|
||||
currentItemId: string;
|
||||
itemType: "Movie" | "Series" | "MusicAlbum" | "Audio";
|
||||
itemKind: MediaKind;
|
||||
genres?: string[];
|
||||
people?: Person[];
|
||||
artistIds?: string[];
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
let {
|
||||
currentItemId,
|
||||
itemType,
|
||||
itemKind,
|
||||
genres = [],
|
||||
people = [],
|
||||
artistIds = [],
|
||||
@@ -45,9 +45,9 @@
|
||||
|
||||
let items: MediaItem[] = [];
|
||||
|
||||
// First, try to use the Jellyfin Similar Items API (preferred method)
|
||||
// First, try to use the Similar Items API (preferred method)
|
||||
// This works for Movies and Series (most common cases)
|
||||
if (["Movie", "Series"].includes(itemType)) {
|
||||
if (itemKind === "movie" || itemKind === "series") {
|
||||
try {
|
||||
const result = await repo.getSimilarItems(currentItemId, limit);
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
@@ -65,10 +65,14 @@
|
||||
// Fallback: Load by genres using search (works for all item types)
|
||||
if (genres && genres.length > 0) {
|
||||
try {
|
||||
// Search by first genre to find related items
|
||||
// Search by first genre to find related items. This single-kind query
|
||||
// maps the neutral kind to the concrete Jellyfin item type it needs.
|
||||
const searchTerm = genres[0];
|
||||
const itemTypeForKind: Record<string, string> = {
|
||||
movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist",
|
||||
};
|
||||
const result = await repo.search(searchTerm, {
|
||||
includeItemTypes: itemType === "MusicAlbum" ? ["MusicAlbum"] : itemType === "Audio" ? ["Audio"] : [itemType],
|
||||
includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"],
|
||||
limit: limit * 2
|
||||
});
|
||||
|
||||
@@ -79,7 +83,7 @@
|
||||
}
|
||||
|
||||
// For music albums, also try to load by artist (if we don't have enough from similar API)
|
||||
if (itemType === "MusicAlbum" && artistIds && artistIds.length > 0 && items.length === 0) {
|
||||
if (itemKind === "album" && artistIds && artistIds.length > 0 && items.length === 0) {
|
||||
try {
|
||||
// Search for other albums by artist name from first artist
|
||||
const result = await repo.search(artistIds[0], {
|
||||
@@ -109,14 +113,14 @@
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (itemType) {
|
||||
case "Movie":
|
||||
switch (itemKind) {
|
||||
case "movie":
|
||||
return "Related Movies";
|
||||
case "Series":
|
||||
case "series":
|
||||
return "Related Shows";
|
||||
case "MusicAlbum":
|
||||
case "album":
|
||||
return "Related Albums";
|
||||
case "Audio":
|
||||
case "track":
|
||||
return "Related Tracks";
|
||||
default:
|
||||
return "Related Items";
|
||||
@@ -133,7 +137,7 @@
|
||||
|
||||
{#if loading}
|
||||
<!-- Skeleton loading state -->
|
||||
{@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"}
|
||||
{@const isMusicContent = itemKind === "album" || itemKind === "track"}
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<CachedImage
|
||||
itemId={season.id}
|
||||
imageType="Primary"
|
||||
tag={season.primaryImageTag}
|
||||
tag={season.imageId}
|
||||
maxWidth={200}
|
||||
alt={seasonName}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="text-gray-400 text-right">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
|
||||
<!-- Download Button Placeholder -->
|
||||
@@ -421,7 +421,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 1"],
|
||||
albumName: "Album 1",
|
||||
albumId: "album-1",
|
||||
runTimeTicks: 1800000000, // 3 minutes
|
||||
durationMs: 180000, // 3 minutes
|
||||
primaryImageTag: "tag1",
|
||||
indexNumber: 1,
|
||||
},
|
||||
@@ -84,7 +84,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 2"],
|
||||
albumName: "Album 2",
|
||||
albumId: "album-2",
|
||||
runTimeTicks: 2400000000, // 4 minutes
|
||||
durationMs: 240000, // 4 minutes
|
||||
primaryImageTag: "tag2",
|
||||
indexNumber: 2,
|
||||
},
|
||||
@@ -96,7 +96,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 3", "Artist 4"],
|
||||
albumName: "Album 3",
|
||||
albumId: "album-3",
|
||||
runTimeTicks: 3000000000, // 5 minutes
|
||||
durationMs: 300000, // 5 minutes
|
||||
indexNumber: 3,
|
||||
},
|
||||
];
|
||||
@@ -187,7 +187,7 @@ describe("TrackList", () => {
|
||||
const tracksWithoutDuration: MediaItem[] = [
|
||||
{
|
||||
...mockTracks[0],
|
||||
runTimeTicks: undefined,
|
||||
durationMs: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
tag={displayMedia?.imageId}
|
||||
maxWidth={800}
|
||||
alt=""
|
||||
class="w-full h-full object-cover blur-3xl opacity-30"
|
||||
@@ -223,7 +223,7 @@
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
tag={displayMedia?.imageId}
|
||||
maxWidth={500}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
<CachedImage
|
||||
itemId={displayMedia.albumId || displayMedia.id}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
tag={displayMedia.imageId}
|
||||
maxWidth={100}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
<div
|
||||
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
|
||||
>
|
||||
{#if imageId && $nextEpisodeItem.primaryImageTag}
|
||||
{#if imageId && $nextEpisodeItem.imageId}
|
||||
<CachedImage
|
||||
itemId={imageId}
|
||||
imageType="Primary"
|
||||
tag={$nextEpisodeItem.primaryImageTag}
|
||||
tag={$nextEpisodeItem.imageId}
|
||||
maxHeight={200}
|
||||
alt={$nextEpisodeItem.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
let dragDisabled = $state(true);
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function formatDuration(ticks?: number | null): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms) return "";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
@@ -184,11 +184,11 @@
|
||||
|
||||
<!-- Artwork -->
|
||||
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
|
||||
{#if item.primaryImageTag}
|
||||
{#if item.imageId}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
tag={item.imageId}
|
||||
maxWidth={80}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -210,7 +210,7 @@
|
||||
|
||||
<!-- Duration -->
|
||||
<span class="text-xs text-gray-500 flex-shrink-0">
|
||||
{formatDuration(item.runTimeTicks)}
|
||||
{formatDuration(item.durationMs)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ function makeEpisode(): MediaItem {
|
||||
return {
|
||||
id: "ep1",
|
||||
name: "Episode 1",
|
||||
type: "Episode",
|
||||
runTimeTicks: 24 * 60 * 10_000_000, // 24 min
|
||||
kind: "episode",
|
||||
durationMs: 24 * 60 * 1000, // 24 min
|
||||
} as MediaItem;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,9 +165,9 @@
|
||||
// Use known duration from media item (runTimeTicks is in 10M ticks/second)
|
||||
// Fallback to video element duration for direct streams
|
||||
const duration = $derived.by(() => {
|
||||
// Explicitly check if runTimeTicks exists and is a valid number
|
||||
if (media && media.runTimeTicks && media.runTimeTicks > 0) {
|
||||
return media.runTimeTicks / 10_000_000;
|
||||
// Explicitly check if durationMs exists and is a valid number
|
||||
if (media && media.durationMs && media.durationMs > 0) {
|
||||
return media.durationMs / 1000;
|
||||
}
|
||||
// Otherwise use the video element's duration
|
||||
return videoDuration;
|
||||
@@ -180,7 +180,7 @@
|
||||
console.log("[VideoPlayer] No media or mediaStreams available");
|
||||
return [];
|
||||
}
|
||||
const tracks = media.mediaStreams.filter(stream => stream.type === "Audio");
|
||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
||||
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
|
||||
return tracks;
|
||||
});
|
||||
@@ -243,7 +243,7 @@
|
||||
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
||||
return [];
|
||||
}
|
||||
const tracks = media.mediaStreams.filter(stream => stream.type === "Subtitle");
|
||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
|
||||
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
||||
return tracks;
|
||||
});
|
||||
@@ -386,7 +386,7 @@
|
||||
// Check if we're near the end of the video - if so, this is likely
|
||||
// end-of-stream rather than a real error. Jellyfin transcoded HLS
|
||||
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
|
||||
const knownDuration = media?.runTimeTicks ? media.runTimeTicks / 10_000_000 : videoDuration;
|
||||
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
|
||||
const effectiveTime = currentTime + seekOffset;
|
||||
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
// Build subtitle tracks for native player
|
||||
const subtitleTracks = [];
|
||||
if (media.mediaStreams && mediaSourceId) {
|
||||
const subtitles = media.mediaStreams.filter(s => s.type === "Subtitle");
|
||||
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
|
||||
for (const sub of subtitles) {
|
||||
try {
|
||||
const url = await getSubtitleUrl(sub.index);
|
||||
@@ -1223,7 +1223,7 @@
|
||||
needsTranscoding: false,
|
||||
// Now-playing metadata so the lockscreen/miniplayer show the item.
|
||||
artist: media.seriesName ?? null,
|
||||
primaryImageTag: media.primaryImageTag ?? null,
|
||||
primaryImageTag: media.imageId ?? null,
|
||||
serverId: media.serverId ?? null,
|
||||
// Real duration so the lockscreen scrubber has a range to draw.
|
||||
durationSeconds: duration > 0 ? duration : null,
|
||||
@@ -1626,11 +1626,11 @@
|
||||
{#if !isMediaReady}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black">
|
||||
<!-- Poster/Title Card -->
|
||||
{#if media?.primaryImageTag}
|
||||
{#if media?.imageId}
|
||||
<CachedImage
|
||||
itemId={media.id}
|
||||
imageType="Primary"
|
||||
tag={media.primaryImageTag}
|
||||
tag={media.imageId}
|
||||
maxHeight={1080}
|
||||
alt={media?.name || "Video"}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
|
||||
@@ -121,11 +121,11 @@
|
||||
class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden">
|
||||
{#if playlist.primaryImageTag}
|
||||
{#if playlist.imageId}
|
||||
<CachedImage
|
||||
itemId={playlist.id}
|
||||
imageType="Primary"
|
||||
tag={playlist.primaryImageTag}
|
||||
tag={playlist.imageId}
|
||||
maxWidth={80}
|
||||
alt={playlist.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
Reference in New Issue
Block a user