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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user