Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3.
750 lines
29 KiB
Svelte
750 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 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";
|
|
|
|
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}
|
|
/>
|
|
<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>
|