chore(format): run prettier over src/ and scripts/

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.
This commit is contained in:
2026-08-21 17:41:44 +02:00
parent d095e1f410
commit ad48d89dfe
199 changed files with 4698 additions and 3453 deletions
+4 -5
View File
@@ -41,7 +41,9 @@
{#if $isAuthLoading}
<div class="min-h-full flex items-center justify-center">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<div
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if $isAuthenticated}
<div class="h-full flex flex-col overflow-hidden">
@@ -64,9 +66,6 @@
<BottomUi />
<!-- Sleep Timer Modal -->
<SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => showSleepTimerModal = false}
/>
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => (showSleepTimerModal = false)} />
</div>
{/if}
+61 -29
View File
@@ -2,7 +2,14 @@
import { onMount, getContext } from "svelte";
import { goto } from "$app/navigation";
import type { Library, MediaItem } from "$lib/api/types";
import { library, libraries, libraryItems, isLibraryLoading, currentLibrary, selectedGenres } from "$lib/stores/library";
import {
library,
libraries,
libraryItems,
isLibraryLoading,
currentLibrary,
selectedGenres,
} from "$lib/stores/library";
import { isServerReachable } from "$lib/stores/connectivity";
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
@@ -31,15 +38,13 @@
const currentLibraryHasDedicatedPage = $derived(
$currentLibrary?.collectionType === "music" ||
$currentLibrary?.collectionType === "tvshows" ||
$currentLibrary?.collectionType === "movies"
);
const showInlineLibraryContent = $derived(
!!$currentLibrary && !currentLibraryHasDedicatedPage
$currentLibrary?.collectionType === "movies",
);
const showInlineLibraryContent = $derived(!!$currentLibrary && !currentLibraryHasDedicatedPage);
// Filter out Playlist libraries - they belong in Music sub-library
const visibleLibraries = $derived.by(() => {
return $libraries.filter(lib => lib.collectionType !== "playlists");
return $libraries.filter((lib) => lib.collectionType !== "playlists");
});
// The overview is a mosaic: rows of one height, tiles of their own widths, so
@@ -188,7 +193,12 @@
aria-label="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<h1 class="text-2xl font-bold text-white">{$currentLibrary?.name}</h1>
@@ -210,29 +220,49 @@
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">Your Libraries</h1>
<div class="flex items-center gap-1">
<!-- Favourites cut across libraries, so they live beside the library
<!-- Favourites cut across libraries, so they live beside the library
list rather than inside one. ux-flows §5C.2.
TRACES: UR-067 | DR-117 -->
<button
onclick={() => goto('/library/favorites')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Favourites"
aria-label="Favourites"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
<button
onclick={() => goto('/settings')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Settings"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
onclick={() => goto("/library/favorites")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Favourites"
aria-label="Favourites"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
<button
onclick={() => goto("/settings")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Settings"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
</div>
@@ -273,7 +303,9 @@
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{/snippet}
</MosaicTile>
+328 -294
View File
@@ -8,7 +8,13 @@
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 {
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";
@@ -140,7 +146,7 @@
// 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'}`);
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})`);
@@ -149,12 +155,15 @@
// 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) {
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");
const musicLibrary = $libraries.find((lib) => lib.collectionType === "music");
if (musicLibrary) {
library.setCurrentLibrary(musicLibrary);
log.debug("Set current library to music library for music item");
@@ -165,12 +174,17 @@
// 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)) {
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'}`);
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`);
@@ -208,7 +222,7 @@
expandedSeasons = initialExpandedSeasons(
seasonData,
current?.id,
$page.url.searchParams.get("episode")
$page.url.searchParams.get("episode"),
);
// Always fetch the focused episode in full. The season fan-out is a
@@ -326,7 +340,7 @@
});
} catch (e) {
log.error("Failed to play album:", e);
alert(`Failed to play album: ${e instanceof Error ? e.message : 'Unknown error'}`);
alert(`Failed to play album: ${e instanceof Error ? e.message : "Unknown error"}`);
}
} else if ($libraryItems.length > 0) {
// For other collections, start playing first item
@@ -350,7 +364,7 @@
});
} catch (e) {
log.error("Failed to shuffle play album:", e);
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
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
@@ -364,9 +378,7 @@
}
// For episode focus view: get all episodes across all seasons
const allEpisodes = $derived(
seasonData.flatMap((s) => s.episodes)
);
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.
@@ -384,7 +396,10 @@
});
const isMusicItem = $derived(
item?.kind === "track" || item?.kind === "album" || item?.kind === "artist" || item?.kind === "playlist"
item?.kind === "track" ||
item?.kind === "album" ||
item?.kind === "artist" ||
item?.kind === "playlist",
);
function handleBackToSeries() {
@@ -411,13 +426,17 @@
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
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
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">
@@ -436,7 +455,7 @@
<BackButton onClick={goBack} label="Back" />
</div>
<PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param -->
<!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.kind === "series" && focusedEpisode}
<EpisodeFocusView
episode={focusedEpisode}
@@ -444,309 +463,324 @@
{allEpisodes}
onBack={handleBackToSeries}
/>
<!-- The same view for a series-less episode, so an episode is never shown
<!-- 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>
<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>
<!-- 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>
{/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"
<!-- 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}
/>
</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,
{: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"
/>
<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>
<!-- 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}
<!-- 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 === "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}
{#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}
<!-- 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
<!-- 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 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}
<!-- 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}
<!-- 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 }}
/>
{/each}
{/if}
</div>
<!-- Discovery content sits below the episodes (UX §5B.4) -->
{#if item.people?.length}
<CastSection people={item.people ?? undefined} />
{/if}
<!-- 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}
{#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}
<!-- 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>
</div>
{/if}
{/if}
</div>
+2 -2
View File
@@ -112,8 +112,8 @@
aria-current={tab === scope ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{tab === scope
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
>
{FAVORITE_SCOPE_LABELS[tab]}
</button>
+11 -4
View File
@@ -97,7 +97,7 @@
const genreRows = $derived($movies.genreRows);
const isLoading = $derived($movies.isLoading);
const hasContent = $derived(
heroItems.length > 0 || continueWatching.length > 0 || recentlyAdded.length > 0
heroItems.length > 0 || continueWatching.length > 0 || recentlyAdded.length > 0,
);
</script>
@@ -106,7 +106,10 @@
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
<button
onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
onclick={() => {
library.setCurrentLibrary(null);
navigateUp("/library");
}}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
@@ -129,7 +132,9 @@
</div>
{:else if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<div
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else}
<div class="space-y-8">
@@ -168,7 +173,9 @@
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
<p class="px-4 text-gray-400">
Nothing here yet. Add some movies to your library to fill this page.
</p>
{/if}
</div>
{/if}
+26 -12
View File
@@ -97,13 +97,15 @@
recentlyPlayed.length > 0 ||
newlyAdded.length > 0 ||
playlists.length > 0 ||
rediscover.length > 0
rediscover.length > 0,
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<div
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else}
<div class="space-y-8 pb-8">
@@ -111,13 +113,21 @@
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">Music</h1>
<button
onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
onclick={() => {
library.setCurrentLibrary(null);
navigateUp("/library");
}}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
</div>
@@ -129,11 +139,7 @@
<!-- Recently Listened -->
{#if recentlyPlayed.length > 0}
<Carousel
title="Recently Listened"
items={recentlyPlayed}
onItemClick={handleItemClick}
/>
<Carousel title="Recently Listened" items={recentlyPlayed} onItemClick={handleItemClick} />
{/if}
<!-- Playlists -->
@@ -176,7 +182,9 @@
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
<p class="px-4 text-gray-400">
Nothing here yet. Start playing some music to fill this page.
</p>
{/if}
<!-- Browse by category -->
@@ -188,8 +196,14 @@
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<div
class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform"
>
<svg
class="w-5 h-5 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d={category.icon} />
</svg>
</div>
@@ -27,19 +27,16 @@
<div class="relative">
<!-- Floating create button -->
<button
onclick={() => showCreateModal = true}
onclick={() => (showCreateModal = true)}
class="fixed bottom-20 right-4 z-40 w-14 h-14 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-full shadow-lg flex items-center justify-center transition-colors"
aria-label="Create playlist"
>
<svg class="w-7 h-7 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
</button>
<GenericMediaListPage {config} />
</div>
<CreatePlaylistModal
isOpen={showCreateModal}
onClose={() => showCreateModal = false}
/>
<CreatePlaylistModal isOpen={showCreateModal} onClose={() => (showCreateModal = false)} />
+11 -4
View File
@@ -114,7 +114,7 @@
heroItems.length > 0 ||
continueWatching.length > 0 ||
nextUp.length > 0 ||
recentlyAdded.length > 0
recentlyAdded.length > 0,
);
</script>
@@ -123,7 +123,10 @@
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
<button
onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
onclick={() => {
library.setCurrentLibrary(null);
navigateUp("/library");
}}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
@@ -146,7 +149,9 @@
</div>
{:else if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<div
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else}
<div class="space-y-8">
@@ -190,7 +195,9 @@
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
<p class="px-4 text-gray-400">
Nothing here yet. Start watching something to fill this page.
</p>
{/if}
</div>
{/if}