Files
jellytau/src/routes/library/[id]/+page.svelte
T
dtourolle 1b70926c36
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
2026-08-09 16:38:07 +02:00

733 lines
29 KiB
Svelte

<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import { kindLabel } from "$lib/utils/mediaKind";
import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
import { downloads } from "$lib/stores/downloads";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import TrackList from "$lib/components/library/TrackList.svelte";
import AlbumDownloadButton from "$lib/components/library/AlbumDownloadButton.svelte";
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
import CastSection from "$lib/components/library/CastSection.svelte";
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte";
import PlaylistDetailView from "$lib/components/library/PlaylistDetailView.svelte";
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
import GenreTags from "$lib/components/library/GenreTags.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
import {
groupEpisodesBySeason,
seasonRedirectTarget,
episodeFocusHref,
episodeRedirectTarget,
seriesPlayHref,
seriesPlayLabel,
initialExpandedSeasons,
type SeasonData,
} from "$lib/components/library/seriesNavigation";
let item = $state<MediaItem | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let seasonData = $state<SeasonData[]>([]);
let directFetchedEpisode = $state<MediaItem | null>(null);
// The episode the viewer is up to. Resolved by Rust (DR-101), not here.
let currentEpisode = $state<MediaItem | null>(null);
// Season ids whose episode list is open. A reading position, not a saved
// preference, so it resets with each load (DR-107).
let expandedSeasons = $state<Set<string>>(new Set());
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
let previousServerReachable = false;
const itemId = $derived($page.params.id);
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
onMount(async () => {
await loadItem();
hasLoadedOnce = true;
});
$effect(() => {
if (itemId) {
loadItem();
hasLoadedOnce = true;
}
});
// Reload when server becomes reachable (handles cache-first timing issue)
$effect(() => {
const serverReachable = $isServerReachable;
// If server just became reachable and we've already loaded, reload to get fresh data
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
loadItem();
}
previousServerReachable = serverReachable;
});
// Re-query when the offline downloaded-only gate changes, so a container's
// contents follow the filter the same way a library listing does.
// TRACES: UR-052 | DR-143
useOfflineFilterReload(() => {
if (itemId) loadItem();
});
async function loadItem() {
if (!itemId) return;
// Only show spinner when navigating to a different item
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
const isNewItem = untrack(() => !item || item.id !== itemId);
if (isNewItem) {
loading = true;
error = null;
seasonData = [];
directFetchedEpisode = null;
currentEpisode = null;
expandedSeasons = new Set();
}
try {
item = await library.loadItem(itemId);
// A season is not a destination — send it to its series, anchored at that
// season, so the episodes of every season stay one continuous list.
// TRACES: UR-062 | DR-103
if (item?.kind === "season") {
const target = seasonRedirectTarget(item);
if (target) {
await goto(target, { replaceState: true });
return;
}
// No seriesId (stale cache / deep link) — fall through to the generic
// rendering below rather than stranding the user.
}
// Nor is an episode. A bare `/library/<episodeId>` — a deep link, an old
// bookmark, a caller that missed `episodeFocusHref` — lands in the series'
// Episode Focus View, so there is exactly one episode surface and it never
// has fewer affordances than the other (ux-flows §5B.1).
// TRACES: UR-058 | DR-142
if (item?.kind === "episode") {
const target = episodeRedirectTarget(item);
if (target) {
await goto(target, { replaceState: true });
return;
}
// Series-less episode: rendered by the Focus View below, series and all.
}
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) {
item.people.forEach((p, i) => {
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
});
}
// Set currentLibrary for music items if not already set
// This ensures navigation to music library pages works correctly
if ((item?.kind === "album" || item?.kind === "artist" || item?.kind === "track") && !$currentLibrary) {
// Find the music library
if ($libraries.length === 0) {
await library.loadLibraries();
}
const musicLibrary = $libraries.find(lib => lib.collectionType === "music");
if (musicLibrary) {
library.setCurrentLibrary(musicLibrary);
console.log("[LibraryDetail] Set current library to music library for music item");
}
}
await library.loadItems(itemId, { limit: 100 });
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load
if ((item?.kind === "movie" || item?.kind === "series" || item?.kind === "episode") && (!item.people || item.people.length === 0)) {
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.kind}...`);
try {
const repo = auth.getRepository();
const fullItem = await repo.getItem(itemId);
console.log(`[LibraryDetail] - Reloaded has people? ${fullItem.people ? `YES (${fullItem.people.length})` : 'NO'}`);
if (fullItem.people && fullItem.people.length > 0) {
item = fullItem;
console.log(`[LibraryDetail] ✓ Updated item with ${fullItem.people.length} people`);
fullItem.people.forEach((p, i) => {
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
});
}
} catch (e) {
console.warn(`Could not reload ${item?.kind} with full cast data:`, e);
}
}
// For Series, load every episode across all seasons plus the episode the
// viewer is up to. Both come from Rust: the season fan-out (and the
// flat-series fallback for shows whose children are episodes rather than
// season folders) is Jellyfin's shape, and "which episode is current" is
// domain policy — neither belongs in the presentation layer.
// TRACES: UR-062 | DR-101, DR-102
if (item?.kind === "series") {
const repo = auth.getRepository();
const seasons = $libraryItems.filter((i) => i.kind === "season");
const [episodes, current] = await Promise.all([
repo.getSeriesEpisodes(itemId),
// Best-effort: a series still renders if the anchor cannot be resolved.
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
console.warn("Could not resolve the current episode:", e);
return null;
}),
]);
seasonData = groupEpisodesBySeason(seasons, episodes);
currentEpisode = current;
// Open only the season the viewer is in (DR-107).
expandedSeasons = initialExpandedSeasons(
seasonData,
current?.id,
$page.url.searchParams.get("episode")
);
// Always fetch the focused episode in full. The season fan-out is a
// *list* query, so its episodes carry no cast and no genres — the Focus
// View would render a bare hero with the sections missing. This also
// still covers the original case: an episode id the fan-out never
// returned at all (an ID mismatch between APIs).
// TRACES: UR-058 | DR-142
const episodeIdParam = $page.url.searchParams.get("episode");
if (episodeIdParam) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
// Best-effort: the list entry still renders a usable hero.
console.warn("Could not fetch focused episode directly:", episodeIdParam);
}
}
}
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load item";
} finally {
loading = false;
}
}
// Images now handled by CachedImage component
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);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function handleItemClick(clickedItem: MediaItem | Library) {
if (!("kind" in clickedItem)) {
// Library item - navigate to library
goto(`/library/${clickedItem.id}`);
return;
}
// A ChannelFolderItem can be either a folder (drill in) or a playable leaf.
// Route non-folder channel items straight to the player.
if (clickedItem.kind === "channelItem" || clickedItem.kind === "liveChannel") {
goto(`/player/${clickedItem.id}`);
return;
}
switch (clickedItem.kind) {
// A season link lands on its series, anchored at that season — seasons
// have no page of their own (DR-103).
case "season":
goto(seasonRedirectTarget(clickedItem) ?? `/library/${clickedItem.id}`);
break;
// An episode always opens in the context of its series (ux-flows §5B.1).
case "episode":
goto(episodeFocusHref(clickedItem));
break;
case "series":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
case "movie":
goto(`/library/${clickedItem.id}`);
break;
default:
goto(`/player/${clickedItem.id}`);
break;
}
}
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
// This fixes Android playback issues where navigation-based approach was hanging
function toggleSeason(seasonId: string) {
// Reassign rather than mutate — a Set mutation is invisible to $state.
const next = new Set(expandedSeasons);
if (!next.delete(seasonId)) next.add(seasonId);
expandedSeasons = next;
}
function handleEpisodeClick(episode: MediaItem) {
// Swap focus to the episode in place; playback starts from the focus view's
// own Play button, never from a list tap (ux-flows §5B.1, §5B.5).
goto(episodeFocusHref(episode));
}
async function handlePlayAll() {
// A movie is a leaf — play it directly. (Episodes never get here; they
// play from the Focus View's own hero button.)
if (item?.kind === "movie") {
goto(`/player/${itemId}`);
} else if (item?.kind === "series" && itemId) {
// Open the episode the viewer is up to, where an explicit Play/Resume
// commits. Play on a container navigates; Play on a leaf plays.
// TRACES: UR-062 | DR-102
const target = seriesPlayHref(itemId, currentEpisode);
if (target) goto(target);
} else if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks)
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const firstTrack = $libraryItems[0];
await commands.playerPlayAlbumTrack(repositoryHandle, {
albumId: item.id,
albumName: item.name,
trackId: firstTrack.id,
shuffle: false,
});
} catch (e) {
console.error("Failed to play album:", e);
alert(`Failed to play album: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if ($libraryItems.length > 0) {
// For other collections, start playing first item
goto(`/player/${$libraryItems[0].id}?queue=parent:${itemId}`);
}
}
async function handleShufflePlay() {
if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command with shuffle
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
// Pick a random track to start with
const randomTrack = $libraryItems[Math.floor(Math.random() * $libraryItems.length)];
await commands.playerPlayAlbumTrack(repositoryHandle, {
albumId: item.id,
albumName: item.name,
trackId: randomTrack.id,
shuffle: true,
});
} catch (e) {
console.error("Failed to shuffle play album:", e);
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if (item?.kind === "series" && allEpisodes.length > 0) {
// Shuffle a *series* means a random episode, not a random season — the
// player has nothing to do with a season id.
const random = allEpisodes[Math.floor(Math.random() * allEpisodes.length)];
goto(`/player/${random.id}?restart=true`);
} else if ($libraryItems.length > 0) {
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
}
}
// For episode focus view: get all episodes across all seasons
const allEpisodes = $derived(
seasonData.flatMap((s) => s.episodes)
);
const playLabel = $derived(item?.kind === "series" ? seriesPlayLabel(currentEpisode) : "Play");
// An empty series has nowhere for the hero button to lead.
const canPlay = $derived(item?.kind !== "series" || currentEpisode !== null);
// The focused episode, as complete as we can make it: the full item fetched
// above layered over the list entry, so cast/genres are present without losing
// anything the fan-out knew. Either source alone is enough to render.
const focusedEpisode = $derived.by(() => {
if (!focusedEpisodeId) return null;
const listed = allEpisodes.find((e) => e.id === focusedEpisodeId) ?? null;
const fetched = directFetchedEpisode?.id === focusedEpisodeId ? directFetchedEpisode : null;
if (listed && fetched) return { ...listed, ...fetched };
return fetched ?? listed;
});
const isMusicItem = $derived(
item?.kind === "track" || item?.kind === "album" || item?.kind === "artist" || item?.kind === "playlist"
);
function handleBackToSeries() {
// Navigate to series page without the episode param
goto(`/library/${itemId}`);
}
function goBack() {
// Prefer real history so the user returns to wherever they came from
// (libraries grid, a list page, a parent album/artist, search, etc.),
// falling back to the libraries overview on a fresh deep-link.
navigateBack("/library");
}
</script>
<div class="relative">
<!-- Backdrop -->
{#if item?.backdropImageTags?.[0]}
<div class="absolute inset-0 -z-10 h-96 overflow-hidden">
<CachedImage
itemId={item.id}
imageType="Backdrop"
tag={item.backdropImageTags[0]}
maxWidth={1920}
class="w-full h-full object-cover opacity-30"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
</div>
{/if}
{#if loading}
<div class="flex justify-center py-12">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if error}
<div class="text-center py-12">
<p class="text-red-400">{error}</p>
<button
onclick={() => goto("/library")}
class="mt-4 text-[var(--color-jellyfin)] hover:underline"
>
Back to library
</button>
</div>
{:else if item}
<!-- Person Detail View - shown for Person items -->
{#if item.kind === "person"}
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.kind === "series" && focusedEpisode}
<EpisodeFocusView
episode={focusedEpisode}
series={item}
{allEpisodes}
onBack={handleBackToSeries}
/>
<!-- The same view for a series-less episode, so an episode is never shown
through a second, lesser surface. TRACES: UR-058 | DR-142 -->
{:else if item.kind === "episode"}
<EpisodeFocusView episode={item} series={null} allEpisodes={[]} onBack={goBack} />
{:else}
<div class="space-y-8">
<!-- Back navigation -->
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<!-- Header with item info -->
<div class="flex gap-6">
<!-- Poster -->
<div class="flex-shrink-0 w-48">
{#if item.imageId}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.imageId}
maxWidth={400}
alt={item.name}
class="w-full {isMusicItem ? 'aspect-square' : ''} rounded-lg shadow-lg"
/>
{:else}
<div class="w-full {isMusicItem ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
{#if isMusicItem}
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
{:else}
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
{/if}
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 space-y-4">
<div>
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
{#if item.artistItems?.length || item.artists?.length}
<p class="text-lg text-gray-400 mt-1">
<ArtistLinks
artistItems={item.artistItems}
artists={item.artists}
linkClass="text-lg text-[var(--color-jellyfin)] hover:underline"
textClass="text-gray-400"
/>
</p>
{:else if item.productionYear}
<p class="text-lg text-gray-400 mt-1">{item.productionYear}</p>
{/if}
</div>
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-400">
{#if kindLabel(item.kind)}
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{kindLabel(item.kind)}</span>
{/if}
{#if item.durationMs}
<span>{formatDuration(item.durationMs)}</span>
{/if}
{#if item.communityRating}
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{item.communityRating.toFixed(1)}
</span>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-3 flex-wrap">
{#if canPlay}
<button
onclick={handlePlayAll}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{playLabel}
</button>
{/if}
{#if item.kind !== "movie"}
<button
onclick={handleShufflePlay}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
</svg>
Shuffle
</button>
{/if}
{#if item.kind === "album"}
<AlbumDownloadButton
albumId={item.id}
albumName={item.name}
tracks={$libraryItems}
/>
{:else if item.kind === "series"}
<SeriesDownloadButton
seriesId={item.id}
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
<ClearHistoryButton
itemId={item.id}
itemName={item.name}
scope="series"
onCleared={loadItem}
/>
{:else if item.kind === "movie"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
isMovie={true}
size="lg"
/>
{/if}
<!-- Favourite. Sits with Play/Download rather than in the header,
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
<FavoriteButton
itemId={item.id}
isFavorite={resolveIsFavorite(item, $favoriteOverrides)}
size="lg"
className="self-center"
/>
</div>
<!-- Overview -->
{#if item.overview}
<p class="text-gray-300 leading-relaxed max-w-2xl">{item.overview}</p>
{/if}
</div>
</div>
<!-- Crew Links - for Movies and Series -->
{#if item.people && (item.kind === "movie" || item.kind === "series")}
<div class="space-y-2">
{#if item.people.some(p => p.type === "Director")}
<CrewLinks
people={item.people ?? undefined}
roleFilter={["Director"]}
label="Directed by"
maxShow={3}
/>
{/if}
{#if item.people.some(p => p.type === "Writer")}
<CrewLinks
people={item.people ?? undefined}
roleFilter={["Writer"]}
label="Written by"
maxShow={3}
/>
{/if}
{#if item.people.some(p => p.type === "Composer")}
<CrewLinks
people={item.people ?? undefined}
roleFilter={["Composer"]}
label="Music by"
maxShow={2}
/>
{/if}
</div>
{/if}
<!-- Genre Tags -->
{#if item.genres?.length}
<div>
<GenreTags genres={item.genres ?? undefined} maxShow={6} itemKind={item.kind} />
</div>
{/if}
<!-- Cast / Related — for Movies these sit above the content block; for
Series they render *below* the seasons instead, so continuation
content precedes discovery content (UX §5B.4). Episodes never reach
here — they render through EpisodeFocusView (§5B.1). -->
{#if item.kind !== "series"}
<!-- Cast Section - for Movies -->
{#if item.kind === "movie" && item.people?.length}
<CastSection people={item.people ?? undefined} />
{/if}
<!-- Related Items Section - for Movies -->
{#if item.kind === "movie" && (item.genres?.length || item.people?.length)}
<RelatedItemsSection
currentItemId={item.id}
itemKind={item.kind}
genres={item.genres ?? undefined}
people={item.people ?? undefined}
limit={12}
/>
{/if}
{/if}
<!-- Content items -->
<div>
{#if item.kind === "album"}
<!-- Tracks in list view -->
<div class="space-y-8">
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">Tracks</h2>
<TrackList
tracks={$libraryItems}
loading={$isLibraryLoading}
showArtist={false}
showAlbum={false}
showDownload={true}
context={{ type: "album", albumId: item.id, albumName: item.name }}
/>
</div>
<!-- Related Albums -->
{#if item.genres?.length || item.artistItems?.length}
<RelatedItemsSection
currentItemId={item.id}
itemKind="album"
genres={item.genres ?? undefined}
artistIds={item.artistItems?.map(a => a.id)}
limit={12}
/>
{/if}
</div>
{:else if item.kind === "series"}
<!-- Series: Seasons with episodes -->
<div class="space-y-8">
{#if $isLibraryLoading}
<div class="flex justify-center py-8">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if seasonData.length === 0}
<div class="text-center py-12 text-gray-400">
<p>No seasons found</p>
</div>
{:else}
{#each seasonData as { season, episodes } (season.id)}
<SeasonSection
{season}
{episodes}
focusedEpisodeId={focusedEpisodeId ?? undefined}
currentEpisodeId={currentEpisode?.id}
expanded={expandedSeasons.has(season.id)}
onToggle={() => toggleSeason(season.id)}
onEpisodeClick={handleEpisodeClick}
onHistoryCleared={loadItem}
/>
{/each}
{/if}
<!-- Discovery content sits below the episodes (UX §5B.4) -->
{#if item.people?.length}
<CastSection people={item.people ?? undefined} />
{/if}
{#if item.genres?.length || item.people?.length}
<RelatedItemsSection
currentItemId={item.id}
itemKind={item.kind}
genres={item.genres ?? undefined}
people={item.people ?? undefined}
limit={12}
/>
{/if}
</div>
{:else if item.kind === "artist"}
<!-- Enhanced artist detail view with discography -->
<ArtistDetailView artist={item} />
{:else if item.kind === "playlist"}
<!-- Playlist detail view with track management -->
<PlaylistDetailView playlist={item} />
{:else}
<!-- Other content in grid view -->
<LibraryGrid
title="Contents"
items={$libraryItems}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
{/if}
</div>
</div>
{/if}
{/if}
</div>