Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
787 lines
30 KiB
Svelte
787 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 { 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 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) {
|
|
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)}</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>
|