Files
jellytau/src/routes/library/[id]/+page.svelte
dtourolle 45144cb6b0 feat(video): render mpv behind the webview, and collapse duplicated helpers
DR-231 with the design the failed reparent forced. mpv's render API draws into
an FBO we own; the texture is composited by `gdk_cairo_draw_from_gl()` in the
default vbox's own `draw` handler. GTK draws a container before its children, so
the webview lands on top for free — no reparenting, no GtkOverlay, and nothing a
Tauri upgrade can invalidate by assuming its own widget layout.

Split so Windows inherits the useful half: `mpv_render` is the portable side
(render context, framebuffer, GL resolution) and `video_surface` is the GTK side
that consumes it. Nothing in the former is GTK-aware.

Three things the spike paid for, carried over rather than rediscovered:

  - libepoxy exports GL entry points as *data* symbols. `dlsym("epoxy_glFoo")`
    returns the address *of a function pointer*, not of code — returning it
    makes mpv jump into non-executable data and take SIGSEGV on the first GL
    call. The value is read out of that location instead.
  - Frame pacing goes through mpv's update callback plus `report_swap`. Its
    absence looks like a GPU or compositing limit (fine in a window, judders at
    fullscreen) and is neither.
  - The render context is created on `realize` and destroyed on `unrealize`,
    with the update callback unregistered *before* the free, so a callback
    cannot land on a freed pointer. That is DR-232 built in from the start
    rather than retrofitted: the spike had no teardown at all, which remains the
    likeliest explanation for the one SIGSEGV it could not reproduce.

Writing it also caught a bug that would have looked like severe stutter: the
update callback flagged a new frame but never asked GTK to repaint, so decoded
frames would only have reached the screen when something else happened to
invalidate the widget.

Still off by default behind JELLYTAU_NATIVE_VIDEO=1. It compiles and is wired;
no frame has been put on screen yet.

Redundant code, continued. `formatSecondsDuration` had no caller. Three
components had hand-rolled `formatDuration`: Queue's was byte-equivalent to the
shared "mm:ss", while EpisodeFocusView and the library page shared an identical
"1h 23m" shape the util did not offer — so that format joins the other two and
all three components now call one function.

A survey for exported symbols referenced only by tests returns 23 more. They are
deliberately left: spot-checking found `setLogForwarder` is the injection seam
for a lazily-initialised forwarder, and `getCachedImageUrl` is the read path of
a thumbnail cache whose management UI exists in Settings. Neither is dead — one
is test infrastructure and the other is an unwired feature, and deleting either
would remove capability while looking like tidying. The list is worth working
through deliberately, not in a playback branch.
2026-08-22 13:45:04 +02:00

776 lines
30 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 { formatDuration } from "$lib/utils/duration";
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 WatchedToggleButton from "$lib/components/library/WatchedToggleButton.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";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("LibraryDetail");
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.
}
log.debug(`✓ Loaded item: ${item?.name} (${item?.kind})`);
log.debug(`- Has people? ${item?.people ? `YES (${item.people.length})` : "NO"}`);
if (item?.people) {
item.people.forEach((p, i) => {
log.debug(` [${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);
log.debug("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)
) {
log.debug(`⚠ People data missing, reloading ${item?.kind}...`);
try {
const repo = auth.getRepository();
const fullItem = await repo.getItem(itemId);
log.debug(
`- Reloaded has people? ${fullItem.people ? `YES (${fullItem.people.length})` : "NO"}`,
);
if (fullItem.people && fullItem.people.length > 0) {
item = fullItem;
log.debug(`✓ Updated item with ${fullItem.people.length} people`);
fullItem.people.forEach((p, i) => {
log.debug(` [${i}] ${p.name} (${p.type})`);
});
}
} catch (e) {
log.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) => {
log.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.
log.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 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) {
log.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) {
log.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, "h m")}</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}
/>
<WatchedToggleButton
itemId={item.id}
watched={allEpisodes.length > 0 && allEpisodes.every((e) => e.userData?.isPlayed)}
scope="series"
showLabel={true}
onChanged={loadItem}
/>
<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"
/>
<!-- A movie is a leaf, so its own played flag is the whole story. -->
<WatchedToggleButton
itemId={item.id}
watched={item.userData?.isPlayed ?? false}
scope="episode"
showLabel={true}
onChanged={loadItem}
/>
{/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>