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
+50 -40
View File
@@ -13,7 +13,12 @@
import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
import { syncService } from "$lib/services/syncService";
import { clearFavorite } from "$lib/stores/favorites";
import { onReconnected as onCatalogReconnected, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
import {
onReconnected as onCatalogReconnected,
refreshSyncStatus,
showServerCatalog,
lastCatalogSync,
} from "$lib/services/offlineCatalog";
import { playbackMode } from "$lib/stores/playbackMode";
import { sessions } from "$lib/stores/sessions";
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
@@ -22,7 +27,12 @@
import BottomUi from "$lib/components/BottomUi.svelte";
import AppHeader from "$lib/components/AppHeader.svelte";
import PendingSyncModal from "$lib/components/sync/PendingSyncModal.svelte";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
import {
isInitialized,
pendingSyncCount,
isAndroid,
showSleepTimerModal,
} from "$lib/stores/appState";
import {
showBottomNav as computeShowBottomNav,
showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
@@ -73,7 +83,7 @@
// gone. See BottomUi.svelte.
const pathname = $derived($page.url.pathname);
const showBottomNav = $derived(
computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated })
computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated }),
);
const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname }));
@@ -81,7 +91,7 @@
// authenticated non-immersive route that doesn't own its own layout. Library
// renders its own AppHeader; settings/player/login get none. (UR-054)
const showGlobalHeader = $derived(
computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated })
computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated }),
);
// Library/settings/player/login own their own full-height flex column
@@ -95,7 +105,7 @@
// bottom UI at all (login, the full-screen player). Exactly one owner, or the
// bar is either ignored or double-padded. (UR-066)
const shellPadsBottom = $derived(
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated })
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }),
);
onMount(async () => {
@@ -138,12 +148,9 @@
// the session overrides for those ids so the next render reads the freshly
// cached server value rather than a stale local guess.
// TRACES: UR-069 | DR-120
stopFavoritesListener = await listen<{ itemIds: string[] }>(
"favorites-changed",
(event) => {
for (const id of event.payload?.itemIds ?? []) clearFavorite(id);
}
);
stopFavoritesListener = await listen<{ itemIds: string[] }>("favorites-changed", (event) => {
for (const id of event.payload?.itemIds ?? []) clearFavorite(id);
});
// Report the network transport to the backend and keep it current, so the
// WiFi-only download gate has real data to act on (UR-053). No-op on
@@ -156,9 +163,7 @@
// not-downloaded until the user opens the Downloads page.
const userId = get(auth).user?.id;
if (userId) {
downloads.refresh(userId).catch((err) =>
log.error("Initial downloads refresh failed:", err)
);
downloads.refresh(userId).catch((err) => log.error("Initial downloads refresh failed:", err));
}
// Start sync service for offline mutation queue
@@ -170,9 +175,7 @@
// whole time. Safe when it isn't: an unreachable server leaves rows queued
// without spending their retry budget (DR-131).
if (get(auth).user?.id) {
commands.syncProcessPending().catch((err) =>
log.debug("Startup sync drain skipped:", err)
);
commands.syncProcessPending().catch((err) => log.debug("Startup sync drain skipped:", err));
}
// Load the last-sync hint for the offline banner. The catalog *index* is no
@@ -210,16 +213,18 @@
connectivity.forceCheck().catch((error) => {
// If check fails, monitoring might not be started yet, so start it
log.debug("Queue status check failed, starting monitoring:", error);
connectivity.startMonitoring(session.serverUrl, {
onServerReconnected: () => {
// Retry session verification when server becomes reachable
auth.retryVerification();
// Resume offline-queued downloads and refresh the catalog.
void onCatalogReconnected();
},
}).catch((monitorError) => {
log.error("Failed to start connectivity monitoring:", monitorError);
});
connectivity
.startMonitoring(session.serverUrl, {
onServerReconnected: () => {
// Retry session verification when server becomes reachable
auth.retryVerification();
// Resume offline-queued downloads and refresh the catalog.
void onCatalogReconnected();
},
})
.catch((monitorError) => {
log.error("Failed to start connectivity monitoring:", monitorError);
});
});
}
});
@@ -280,9 +285,16 @@
{#if isInitialized}
<!-- Offline indicator banner -->
{#if $isAuthenticated && !$isConnected}
<div class="bg-amber-600/90 text-white px-4 py-2 text-sm flex items-center justify-center gap-2 shrink-0">
<div
class="bg-amber-600/90 text-white px-4 py-2 text-sm flex items-center justify-center gap-2 shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
/>
</svg>
<span>You're offline. Some features may be limited.</span>
<!-- The badge is answerable: it opens the queue it counts. Read as
@@ -304,9 +316,11 @@
onclick={() => showServerCatalog.update((v) => !v)}
class="ml-1 bg-white/20 hover:bg-white/30 px-2 py-0.5 rounded-full text-xs transition-colors"
aria-pressed={$showServerCatalog}
title={$lastCatalogSync ? `Catalog last synced ${new Date($lastCatalogSync).toLocaleString()}` : 'Catalog not yet synced'}
title={$lastCatalogSync
? `Catalog last synced ${new Date($lastCatalogSync).toLocaleString()}`
: "Catalog not yet synced"}
>
{$showServerCatalog ? 'Hide server media' : 'Show all server media'}
{$showServerCatalog ? "Hide server media" : "Show all server media"}
</button>
</div>
{/if}
@@ -353,19 +367,15 @@
{/if}
<!-- Sleep Timer Modal (global) -->
<SleepTimerModal
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
<SleepTimerModal isOpen={$showSleepTimerModal} onClose={() => showSleepTimerModal.set(false)} />
<!-- What the offline banner's badge counts (DR-132) -->
<PendingSyncModal
isOpen={showPendingSync}
onClose={() => (showPendingSync = false)}
/>
<PendingSyncModal isOpen={showPendingSync} onClose={() => (showPendingSync = false)} />
{:else}
<div class="flex items-center justify-center h-full">
<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>
{/if}
</div>
+132 -119
View File
@@ -115,7 +115,7 @@
// Playlist libraries live inside the Music landing page, not as top-level shortcuts.
const shortcutLibraries = $derived(
$libraries.filter((lib) => lib.collectionType !== "playlists")
$libraries.filter((lib) => lib.collectionType !== "playlists"),
);
// The shortcut strip is a mosaic row: one height, each tile as wide as its own
@@ -124,7 +124,11 @@
// TRACES: UR-075 | DR-174
const LIBRARY_STRIP_HEIGHT = 132;
const libraryTiles = $derived(
shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib }))
shortcutLibraries.map((lib) => ({
key: lib.id,
ratio: assumedLibraryRatio(lib),
library: lib,
})),
);
function handleLibraryClick(lib: Library) {
@@ -148,9 +152,9 @@
}
const heroItems = $derived($home.heroItems);
const resumeItems = $derived($home.resumeItems.filter(
i => i.kind === "movie" || i.kind === "episode"
));
const resumeItems = $derived(
$home.resumeItems.filter((i) => i.kind === "movie" || i.kind === "episode"),
);
const nextUpItems = $derived($home.nextUpItems);
const latestItems = $derived($home.latestItems);
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
@@ -165,136 +169,145 @@
{#if isLoading}
<div class="h-full flex justify-center items-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}
<div bind:this={homeScroller} class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
<div
bind:this={homeScroller}
class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid &&
$currentMedia &&
$currentMedia.type !== 'Movie' &&
$currentMedia.type !== 'Episode'
? 'pb-40'
: ''}"
>
<div class="space-y-8">
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Your Libraries: quick jump into each dedicated landing page -->
{#if shortcutLibraries.length > 0}
<div>
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
<div class="px-4">
<MosaicGrid
items={libraryTiles}
layout="strip"
targetHeight={LIBRARY_STRIP_HEIGHT}
gap={12}
>
{#snippet tile(entry)}
<MosaicTile
label={entry.library.name}
width={entry.width}
height={entry.height}
itemId={entry.library.id}
imageTag={entry.library.imageTag}
onRatio={entry.reportRatio}
onclick={() => handleLibraryClick(entry.library)}
/>
{/snippet}
</MosaicGrid>
<!-- Your Libraries: quick jump into each dedicated landing page -->
{#if shortcutLibraries.length > 0}
<div>
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
<div class="px-4">
<MosaicGrid
items={libraryTiles}
layout="strip"
targetHeight={LIBRARY_STRIP_HEIGHT}
gap={12}
>
{#snippet tile(entry)}
<MosaicTile
label={entry.library.name}
width={entry.width}
height={entry.height}
itemId={entry.library.id}
imageTag={entry.library.imageTag}
onRatio={entry.reportRatio}
onclick={() => handleLibraryClick(entry.library)}
/>
{/snippet}
</MosaicGrid>
</div>
</div>
</div>
{/if}
{/if}
<!-- Next Movie -->
{#if resumeMovies.length > 0}
<Carousel
title="Next Movie"
items={resumeMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Next Movie -->
{#if resumeMovies.length > 0}
<Carousel
title="Next Movie"
items={resumeMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Next Episode -->
{#if nextUpItems.length > 0}
<Carousel
title="Next Episode"
items={nextUpItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Next Episode -->
{#if nextUpItems.length > 0}
<Carousel
title="Next Episode"
items={nextUpItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Recently Listened -->
{#if recentlyPlayedAudio.length > 0}
<Carousel
title="Recently Listened"
items={recentlyPlayedAudio}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Recently Listened -->
{#if recentlyPlayedAudio.length > 0}
<Carousel
title="Recently Listened"
items={recentlyPlayedAudio}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Continue Watching -->
{#if resumeItems.length > 0}
<Carousel
title="Continue Watching"
items={resumeItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Continue Watching -->
{#if resumeItems.length > 0}
<Carousel
title="Continue Watching"
items={resumeItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Recently Added -->
{#if latestItems.length > 0}
<Carousel
title="Recently Added"
items={latestItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Recently Added -->
{#if latestItems.length > 0}
<Carousel
title="Recently Added"
items={latestItems}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
/>
{/if}
<!-- Favourites. Hidden entirely when a category has nothing in it —
<!-- Favourites. Hidden entirely when a category has nothing in it —
empty rows on a fresh install read as broken. ux-flows §5C.2.
TRACES: UR-067 | DR-118 -->
{#if favoriteMovies.length > 0}
<Carousel
title="Favourite Movies"
items={favoriteMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=movies")}
/>
{/if}
{#if favoriteMovies.length > 0}
<Carousel
title="Favourite Movies"
items={favoriteMovies}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=movies")}
/>
{/if}
{#if favoriteShows.length > 0}
<Carousel
title="Favourite Shows"
items={favoriteShows}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=tv")}
/>
{/if}
{#if favoriteShows.length > 0}
<Carousel
title="Favourite Shows"
items={favoriteShows}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=tv")}
/>
{/if}
{#if favoriteMusic.length > 0}
<Carousel
title="Favourite Music"
items={favoriteMusic}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=music")}
/>
{/if}
{#if favoriteMusic.length > 0}
<Carousel
title="Favourite Music"
items={favoriteMusic}
onItemClick={handleItemClick}
onItemLongPress={handleItemLongPress}
showAll={() => goto("/library/favorites?scope=music")}
/>
{/if}
<!-- Quick Access -->
<div class="pt-4 px-4">
<button
onclick={() => goto("/library")}
class="text-[var(--color-jellyfin)] hover:underline text-lg"
>
Browse all libraries →
</button>
</div>
<!-- Quick Access -->
<div class="pt-4 px-4">
<button
onclick={() => goto("/library")}
class="text-[var(--color-jellyfin)] hover:underline text-lg"
>
Browse all libraries →
</button>
</div>
</div>
</div>
{/if}
+33 -11
View File
@@ -16,7 +16,13 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { downloads, activeDownloads, pendingDownloads, failedDownloads, waitingForNetwork } from "$lib/stores/downloads";
import {
downloads,
activeDownloads,
pendingDownloads,
failedDownloads,
waitingForNetwork,
} from "$lib/stores/downloads";
import { areDownloadsAllowed } from "$lib/services/networkType";
import { auth } from "$lib/stores/auth";
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
@@ -53,9 +59,7 @@
// Transfers = everything still in flight or waiting. Completed rows are
// deliberately excluded — they live in Downloaded, not here.
const transfers = $derived(
$activeDownloads.concat($pendingDownloads).concat($failedDownloads)
);
const transfers = $derived($activeDownloads.concat($pendingDownloads).concat($failedDownloads));
async function pauseAll() {
for (const download of $activeDownloads) {
@@ -142,16 +146,28 @@
{:else}
<!-- WiFi-only gate notice (UR-053): explains an otherwise stuck-looking queue -->
{#if $waitingForNetwork && transfers.length > 0}
<div class="flex items-start gap-3 rounded-lg border border-amber-700 bg-amber-900/20 p-4" role="status">
<svg class="mt-0.5 h-5 w-5 shrink-0 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8.111 15.111a5.5 5.5 0 017.778 0M4.929 11.929a10 10 0 0114.142 0M12 21h.01" />
<div
class="flex items-start gap-3 rounded-lg border border-amber-700 bg-amber-900/20 p-4"
role="status"
>
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 18h.01M8.111 15.111a5.5 5.5 0 017.778 0M4.929 11.929a10 10 0 0114.142 0M12 21h.01"
/>
</svg>
<div>
<p class="font-medium text-amber-200">Waiting for WiFi</p>
<p class="mt-1 text-sm text-amber-200/80">
Downloads are paused because “WiFi Only” is enabled and this device is
on a metered or cellular connection. They'll resume automatically on
an unmetered network.
Downloads are paused because “WiFi Only” is enabled and this device is on a metered or
cellular connection. They'll resume automatically on an unmetered network.
</p>
</div>
</div>
@@ -161,7 +177,13 @@
<div class="text-center py-12 text-gray-400"><p>Loading transfers…</p></div>
{:else if transfers.length === 0}
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
<svg
class="mx-auto mb-4 h-14 w-14 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p class="text-lg font-medium text-gray-300">Nothing downloading</p>
+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}
+35 -8
View File
@@ -28,7 +28,8 @@
// Reject plain HTTP — all connections must use HTTPS
if (serverUrl.trim().toLowerCase().startsWith("http://")) {
localError = "HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).";
localError =
"HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).";
connecting = false;
return;
}
@@ -80,7 +81,9 @@
{#if $isLoading}
<!-- Loading state -->
<div class="flex 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 step === "server"}
<!-- Server connection form -->
@@ -111,7 +114,9 @@
class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
{#if connecting}
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<div
class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
Connecting...
{:else}
Connect
@@ -126,7 +131,12 @@
class="text-gray-400 hover:text-white text-sm flex items-center gap-1"
>
<svg class="w-4 h-4" 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>
Back
</button>
@@ -186,13 +196,28 @@
{#if showPassword}
<!-- eye-off -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
{:else}
<!-- eye -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{/if}
</button>
@@ -211,7 +236,9 @@
class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
{#if loggingIn}
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<div
class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
Signing in...
{:else}
Sign In
+242 -110
View File
@@ -8,13 +8,27 @@
import type { MediaItem, MediaKind } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
import {
queue,
currentQueueItem,
isShuffle,
repeatMode,
hasNext as hasNextStore,
hasPrevious as hasPreviousStore,
} from "$lib/stores/queue";
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player";
import {
playbackPosition,
playbackDuration,
currentMedia as storeCurrentMedia,
} from "$lib/stores/player";
import { get } from "svelte/store";
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
import { shouldReuseActivePlayback, resolvePlayerSurface } from "$lib/components/player/playerSurface";
import {
shouldReuseActivePlayback,
resolvePlayerSurface,
} from "$lib/components/player/playerSurface";
import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
import {
reportPlaybackStart,
@@ -100,7 +114,14 @@
const id = itemId;
const restart = restartParam;
if (id && id !== loadedItemId) {
autoPlayLog.debug("$effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart);
autoPlayLog.debug(
"$effect triggered: loading new item",
id,
"(was:",
loadedItemId,
") restart:",
restart,
);
// restart=true (advancing to next episode) forces start-from-beginning,
// bypassing the resume-progress check.
loadAndPlay(id, restart ? 0 : undefined, restart);
@@ -121,8 +142,7 @@
// treat it as video when it carries a video media stream.
function isVideoChannelItem(item: MediaItem): boolean {
return (
item.kind === "channelItem" &&
(item.mediaStreams?.some((s) => s.kind === "video") ?? false)
item.kind === "channelItem" && (item.mediaStreams?.some((s) => s.kind === "video") ?? false)
);
}
@@ -140,7 +160,15 @@
currentMedia = item;
// Check if this is a non-playable collection type that should be viewed in library instead
const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"];
const collectionKinds: MediaKind[] = [
"album",
"artist",
"series",
"season",
"folder",
"playlist",
"channel",
];
if (item.kind && collectionKinds.includes(item.kind)) {
log.debug("loadAndPlay: Redirecting collection type to library:", item.kind);
goto(`/library/${id}`);
@@ -150,7 +178,8 @@
// Determine if this is video content (Movie, Episode, live TV channels, and
// channel leaf items that carry a video stream).
isLive = item.kind === "liveChannel";
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
isVideo =
item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
// If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer).
@@ -190,7 +219,16 @@
// When forceRestart is set (advancing to a next episode) we always start
// from the beginning, skipping the resume check and resume dialog.
const userId = auth.getUserId();
log.debug("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
log.debug(
"Resume check - userId:",
userId,
"itemId:",
id,
"startPosition:",
startPosition,
"forceRestart:",
forceRestart,
);
// Live streams have no fixed position - never resume.
if (!startPosition && !forceRestart && userId && !isLive) {
@@ -203,7 +241,14 @@
const totalSeconds = item.durationMs / 1000;
const progressPercent = (positionSeconds / totalSeconds) * 100;
log.debug("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
log.debug(
"Resume check - positionSeconds:",
positionSeconds,
"totalSeconds:",
totalSeconds,
"progressPercent:",
progressPercent,
);
// Store for later use regardless of whether dialog is shown
retrievedProgressSeconds = positionSeconds;
@@ -216,10 +261,22 @@
loading = false;
return; // Wait for user decision
} else {
log.debug("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
log.debug(
"Resume check - NOT showing dialog. Position > 30?",
positionSeconds > 30,
"Progress < 90?",
progressPercent < 90,
);
}
} else {
log.debug("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs);
log.debug(
"Resume check - No valid progress found. Has progress?",
!!progress,
"Has position?",
progress?.positionMs,
"Has runtime?",
!!item.durationMs,
);
}
} catch (e) {
log.error("Failed to check saved progress:", e);
@@ -232,12 +289,15 @@
// Check if this item is downloaded locally
const downloadsState = get(downloads);
const localDownload = Object.values(downloadsState.downloads).find(
(d: DownloadInfo) => d.itemId === id && d.status === "completed"
(d: DownloadInfo) => d.itemId === id && d.status === "completed",
);
if (localDownload) {
// Use local file for playback
log.debug("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
log.debug(
"loadAndPlay: Found local download, using offline playback:",
localDownload.filePath,
);
isOfflinePlayback = true;
// Get the storage path and resolve the file's location. A completed
@@ -309,7 +369,12 @@
if (isVideo) {
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
log.debug("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
log.debug(
"loadAndPlay: Using video stream, directPlay:",
playbackInfo.directPlay,
"needsTranscoding:",
playbackInfo.needsTranscoding,
);
mediaSourceId = playbackInfo.mediaSourceId;
// Prefer a completed download over streaming. Audio has done this
@@ -336,7 +401,7 @@
log.debug(
source.isLocal
? "loadAndPlay: Playing downloaded file from disk"
: `loadAndPlay: Using stream URL: ${streamUrl}`
: `loadAndPlay: Using stream URL: ${streamUrl}`,
);
// Set initial position for the video player to seek to after load.
@@ -358,68 +423,105 @@
// For audio, use MPV backend
log.debug("loadAndPlay: Using MPV backend for audio");
// Check if we have a queue parameter (e.g., queue=parent:albumId)
const queueParamValue = queueParam;
if (queueParamValue?.startsWith("parent:")) {
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
log.debug("loadAndPlay: Loading queue from parent:", parentId);
// Check if we have a queue parameter (e.g., queue=parent:albumId)
const queueParamValue = queueParam;
if (queueParamValue?.startsWith("parent:")) {
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
log.debug("loadAndPlay: Loading queue from parent:", parentId);
// Fetch all tracks from the parent (album/playlist)
const result = await repo.getItems(parentId, {
sortBy: "SortName",
sortOrder: "Ascending",
limit: 500,
});
const audioTracks = result.items.filter(t => t.kind === "track");
// Fetch all tracks from the parent (album/playlist)
const result = await repo.getItems(parentId, {
sortBy: "SortName",
sortOrder: "Ascending",
limit: 500,
});
const audioTracks = result.items.filter((t) => t.kind === "track");
if (audioTracks.length > 0) {
// Find the index of the current item in the tracks
const startIndex = audioTracks.findIndex(t => t.id === id);
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
if (audioTracks.length > 0) {
// Find the index of the current item in the tracks
const startIndex = audioTracks.findIndex((t) => t.id === id);
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
log.debug("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex);
log.debug(
"loadAndPlay: Building queue with",
audioTracks.length,
"tracks, startIndex:",
actualStartIndex,
);
// Build queue items with stream URLs
// Add error handling and logging for each track
const queueItems = await Promise.all(audioTracks.map(async (t, idx) => {
try {
log.debug(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`);
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
if (!trackStreamUrl) {
log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
throw new Error(`Failed to get stream URL for ${t.name}`);
}
return {
id: t.id,
title: t.name,
artist: t.artists?.join(", ") || null,
album: t.albumName || null,
duration: t.durationMs ? t.durationMs / 1000 : null,
artworkUrl: t.imageId
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId })
: null,
mediaType: "audio",
streamUrl: trackStreamUrl,
jellyfinItemId: t.id,
};
} catch (e) {
log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
throw e; // Re-throw to fail fast and show error to user
}
}));
// Build queue items with stream URLs
// Add error handling and logging for each track
const queueItems = await Promise.all(
audioTracks.map(async (t, idx) => {
try {
log.debug(
`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`,
);
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
if (!trackStreamUrl) {
log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
throw new Error(`Failed to get stream URL for ${t.name}`);
}
return {
id: t.id,
title: t.name,
artist: t.artists?.join(", ") || null,
album: t.albumName || null,
duration: t.durationMs ? t.durationMs / 1000 : null,
artworkUrl: t.imageId
? repo.getImageUrl(t.albumId || t.id, "Primary", {
maxWidth: 300,
tag: t.imageId,
})
: null,
mediaType: "audio",
streamUrl: trackStreamUrl,
jellyfinItemId: t.id,
};
} catch (e) {
log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
throw e; // Re-throw to fail fast and show error to user
}
}),
);
// Use player_play_queue to set up the backend queue
await commands.playerPlayQueue({
items: queueItems,
startIndex: actualStartIndex,
shuffle: shuffleParam,
} as unknown as PlayQueueRequest);
// Use player_play_queue to set up the backend queue
await commands.playerPlayQueue({
items: queueItems,
startIndex: actualStartIndex,
shuffle: shuffleParam,
} as unknown as PlayQueueRequest);
// Queue will auto-update from Rust backend event
log.debug("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks");
// Queue will auto-update from Rust backend event
log.debug(
"loadAndPlay: Successfully set up queue with",
audioTracks.length,
"tracks",
);
} else {
// Fallback to single item playback
log.debug(
"loadAndPlay: No audio tracks found in parent, falling back to single item",
);
// Use player_play_tracks - backend fetches all metadata from single ID
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
await commands.playerPlayTracks(repositoryHandle, {
trackIds: [item.id],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "",
},
});
// Queue will auto-update from Rust backend event
log.debug("loadAndPlay: Set queue with single item:", item.name);
}
} else {
// Fallback to single item playback
log.debug("loadAndPlay: No audio tracks found in parent, falling back to single item");
// No queue parameter - single item playback
// Use player_play_tracks - backend fetches all metadata from single ID
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
@@ -437,25 +539,6 @@
// Queue will auto-update from Rust backend event
log.debug("loadAndPlay: Set queue with single item:", item.name);
}
} else {
// No queue parameter - single item playback
// Use player_play_tracks - backend fetches all metadata from single ID
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
await commands.playerPlayTracks(repositoryHandle, {
trackIds: [item.id],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "",
},
});
// Queue will auto-update from Rust backend event
log.debug("loadAndPlay: Set queue with single item:", item.name);
}
// Seek to start position if provided
if (startPosition) {
@@ -468,11 +551,22 @@
loading = false;
// Fetch next episode for video episodes (for skip button)
nextEpisodeLog.debug("Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name);
nextEpisodeLog.debug(
"Post-load check: isVideo=",
isVideo,
"currentMedia=",
currentMedia?.kind,
currentMedia?.name,
);
if (isVideo && currentMedia) {
fetchNextEpisode(currentMedia);
} else {
nextEpisodeLog.debug("Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia);
nextEpisodeLog.debug(
"Skipped fetchNextEpisode - isVideo:",
isVideo,
"currentMedia:",
!!currentMedia,
);
}
} catch (e) {
log.error("loadAndPlay error:", e);
@@ -545,7 +639,10 @@
*
* TRACES: UR-004, UR-005, UR-021 | DR-181
*/
async function handleVideoSeek(_positionSeconds: number, audioStreamIndex?: number): Promise<string> {
async function handleVideoSeek(
_positionSeconds: number,
audioStreamIndex?: number,
): Promise<string> {
const repo = auth.getRepository();
const id = itemId;
if (!id) throw new Error("No item ID");
@@ -604,7 +701,13 @@
// and check for next episodes. HTML5 video plays independently of the Rust
// backend queue, so the backend needs these to know what just finished.
const mediaId = currentMedia?.id ?? null;
autoPlayLog.debug("Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId);
autoPlayLog.debug(
"Video ended. currentMedia:",
mediaId,
currentMedia?.name,
"itemId (URL):",
itemId,
);
try {
const repo = auth.getRepository();
const repoHandle = repo.getHandle();
@@ -616,7 +719,14 @@
async function fetchNextEpisode(media: MediaItem) {
nextEpisode = null;
nextEpisodeLog.debug("fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
nextEpisodeLog.debug("fetchNextEpisode called:", {
kind: media.kind,
seriesId: media.seriesId,
seasonId: media.seasonId,
indexNumber: media.indexNumber,
id: media.id,
name: media.name,
});
if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) {
nextEpisodeLog.debug("Skipping - not an episode or missing seasonId/indexNumber");
return;
@@ -624,17 +734,37 @@
try {
const repo = auth.getRepository();
// Fetch all episodes in the season sorted by episode number
const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 });
const episodes = result.items.filter(e => e.kind === "episode");
nextEpisodeLog.debug("Season has", episodes.length, "episodes, current index:", media.indexNumber);
const result = await repo.getItems(media.seasonId, {
sortBy: "IndexNumber",
sortOrder: "Ascending",
limit: 500,
});
const episodes = result.items.filter((e) => e.kind === "episode");
nextEpisodeLog.debug(
"Season has",
episodes.length,
"episodes, current index:",
media.indexNumber,
);
// Find the episode after the current one by index number
const currentIdx = episodes.findIndex(e => e.id === media.id);
const currentIdx = episodes.findIndex((e) => e.id === media.id);
if (currentIdx >= 0 && currentIdx < episodes.length - 1) {
nextEpisode = episodes[currentIdx + 1];
nextEpisodeLog.debug("Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber);
nextEpisodeLog.debug(
"Set nextEpisode:",
nextEpisode.name,
"index:",
nextEpisode.indexNumber,
);
} else {
nextEpisodeLog.debug("No next episode in season (current position:", currentIdx, "of", episodes.length, ")");
nextEpisodeLog.debug(
"No next episode in season (current position:",
currentIdx,
"of",
episodes.length,
")",
);
}
} catch (e) {
nextEpisodeLog.error("Failed to fetch next episode:", e);
@@ -664,9 +794,9 @@
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
</script>
@@ -675,7 +805,9 @@
<div class="bg-[var(--color-surface)] rounded-lg p-6 max-w-md mx-4 shadow-xl">
<h2 class="text-xl font-semibold mb-4">Resume Playback?</h2>
<p class="text-gray-300 mb-2">
You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo ? 'video' : 'audio'}.
You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo
? "video"
: "audio"}.
</p>
<p class="text-gray-400 text-sm mb-6">
Resume from {formatTime(savedProgress.positionSeconds)} or start from the beginning?
@@ -700,11 +832,9 @@
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50 p-4">
<div class="text-center max-w-lg">
<p class="text-red-400 mb-4 text-lg font-semibold">Playback Error</p>
<pre class="text-red-300 mb-4 text-left bg-black/30 p-4 rounded overflow-auto max-h-48 text-sm">{error}</pre>
<button
onclick={handleClose}
class="px-4 py-2 bg-[var(--color-jellyfin)] rounded-lg"
>
<pre
class="text-red-300 mb-4 text-left bg-black/30 p-4 rounded overflow-auto max-h-48 text-sm">{error}</pre>
<button onclick={handleClose} class="px-4 py-2 bg-[var(--color-jellyfin)] rounded-lg">
Back to Library
</button>
</div>
@@ -713,7 +843,9 @@
<!-- "pending" = video whose stream URL has not resolved yet. Showing the
spinner keeps it out of the audio player. -->
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
<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 surface === "video" && streamUrl}
<VideoPlayer
+3 -1
View File
@@ -132,7 +132,9 @@
{:else}
<div class="text-center text-gray-400 mt-12">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
<p>Search your entire library</p>
<p class="text-sm text-gray-500 mt-2">Find music, movies, shows, and more</p>
+13 -6
View File
@@ -18,9 +18,7 @@
<!-- Page Header -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Remote Sessions</h1>
<p class="text-gray-400">
Control playback on other Jellyfin clients
</p>
<p class="text-gray-400">Control playback on other Jellyfin clients</p>
</header>
<!-- Main Content -->
@@ -38,8 +36,15 @@
</div>
{:else}
<!-- Placeholder when no session selected -->
<div class="flex flex-col items-center justify-center p-12 rounded-lg bg-[var(--color-surface)] text-center">
<svg class="w-20 h-20 text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div
class="flex flex-col items-center justify-center p-12 rounded-lg bg-[var(--color-surface)] text-center"
>
<svg
class="w-20 h-20 text-gray-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -57,7 +62,9 @@
</div>
<!-- Help Text -->
<div class="mt-8 p-4 rounded-lg bg-[var(--color-surface)] border border-[var(--color-jellyfin)]/20">
<div
class="mt-8 p-4 rounded-lg bg-[var(--color-surface)] border border-[var(--color-jellyfin)]/20"
>
<h3 class="text-sm font-semibold text-white mb-2">How to use Remote Sessions</h3>
<ul class="text-sm text-gray-400 space-y-1 list-disc list-inside">
<li>Start playing media on another Jellyfin client (TV, web browser, mobile app)</li>
+51 -73
View File
@@ -26,10 +26,7 @@
import PendingSyncList from "$lib/components/sync/PendingSyncList.svelte";
import { library, viewMode } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import {
isNetworkDetectionSupported,
reportNetworkState,
} from "$lib/services/networkType";
import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger";
@@ -442,9 +439,7 @@
<div id="display" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white">Display</h2>
<p class="text-sm text-gray-400 mt-1">
How your library and collections are laid out
</p>
<p class="text-sm text-gray-400 mt-1">How your library and collections are laid out</p>
</div>
<p class="text-sm font-medium text-gray-300 mb-3">Layout</p>
<div class="grid grid-cols-2 gap-3">
@@ -486,10 +481,9 @@
<div class="mb-4">
<h2 class="text-xl font-semibold text-white">Hidden Folders</h2>
<p class="text-sm text-gray-400 mt-1">
Folders to leave out of music browsing and search. Useful when a
music library also holds podcasts or audiobooks. Hidden folders can
still be opened from a direct link, and anything already playing or
downloaded is unaffected.
Folders to leave out of music browsing and search. Useful when a music library also
holds podcasts or audiobooks. Hidden folders can still be opened from a direct link, and
anything already playing or downloaded is unaffected.
</p>
</div>
@@ -497,8 +491,7 @@
<p class="text-sm text-gray-400">Loading folders...</p>
{:else if exclusionCandidates.length === 0}
<p class="text-sm text-gray-400">
No music folders to choose from. Connect to your server to pick
folders to hide.
No music folders to choose from. Connect to your server to pick folders to hide.
</p>
{:else}
<div class="space-y-2">
@@ -506,7 +499,7 @@
<button
onclick={() => toggleExcludedItem(candidate.id)}
class="w-full flex items-center justify-between gap-3 py-3 px-4 rounded-lg text-left transition-all {excludedIds.has(
candidate.id
candidate.id,
)
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
@@ -515,9 +508,7 @@
<span class="min-w-0">
<span class="block font-semibold truncate">{candidate.name}</span>
<span class="block text-xs opacity-75 truncate">
{candidate.isLibrary
? "Whole library"
: `In ${candidate.libraryName}`}
{candidate.isLibrary ? "Whole library" : `In ${candidate.libraryName}`}
</span>
</span>
<span class="text-xs font-semibold uppercase tracking-wide shrink-0">
@@ -527,8 +518,7 @@
{/each}
</div>
<p class="text-xs text-gray-500 mt-3">
Changes apply to listings loaded from now on; reopen a page to see
them take effect.
Changes apply to listings loaded from now on; reopen a page to see them take effect.
</p>
{/if}
</div>
@@ -538,9 +528,7 @@
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white">Crossfade</h2>
<p class="text-sm text-gray-400 mt-1">
Fade between tracks for seamless transitions
</p>
<p class="text-sm text-gray-400 mt-1">Fade between tracks for seamless transitions</p>
</div>
<div class="text-right">
<span class="text-2xl font-bold text-[var(--color-jellyfin)]">
@@ -569,9 +557,7 @@
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white">Gapless Playback</h2>
<p class="text-sm text-gray-400 mt-1">
Eliminate silence between tracks in albums
</p>
<p class="text-sm text-gray-400 mt-1">Eliminate silence between tracks in albums</p>
</div>
<button
onclick={handleGaplessToggle}
@@ -619,8 +605,7 @@
<div class="grid grid-cols-3 gap-3">
<button
onclick={() => handleVolumeLevelChange("loud")}
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
'loud'
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel === 'loud'
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
@@ -629,8 +614,7 @@
</button>
<button
onclick={() => handleVolumeLevelChange("normal")}
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
'normal'
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel === 'normal'
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
@@ -639,8 +623,7 @@
</button>
<button
onclick={() => handleVolumeLevelChange("quiet")}
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
'quiet'
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel === 'quiet'
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
@@ -684,8 +667,7 @@
{#each eqPresets as [name, gains] (name)}
<button
onclick={() => handleEqPreset(gains)}
class="px-3 py-1.5 rounded-full text-sm transition-all {activeEqPreset ===
name
class="px-3 py-1.5 rounded-full text-sm transition-all {activeEqPreset === name
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
@@ -803,8 +785,8 @@
onclick={() => handleEpisodeLimitChange(option.value)}
class="py-3 px-3 rounded-lg transition-all text-sm
{videoSettings.autoPlayMaxEpisodes === option.value
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
<div class="font-semibold">{option.label}</div>
</button>
@@ -820,10 +802,10 @@
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<h3 class="text-xl font-semibold text-white">Streaming Quality</h3>
<p class="text-sm text-gray-400 mt-1 mb-4">
Limit how much bandwidth video streams may use. Lower settings ask the
server to transcode before sending, which saves data on metered or slow
connections at the cost of picture quality. You can also change this for
a single video from the player's quality menu.
Limit how much bandwidth video streams may use. Lower settings ask the server to
transcode before sending, which saves data on metered or slow connections at the cost of
picture quality. You can also change this for a single video from the player's quality
menu.
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
{#each streamingQualities as [quality, label, detail]}
@@ -831,8 +813,8 @@
onclick={() => handleStreamingQualityChange(quality)}
class="py-3 px-3 rounded-lg transition-all text-left
{videoSettings.streamingQuality === quality
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
aria-pressed={videoSettings.streamingQuality === quality}
>
<div class="font-semibold text-sm">{label}</div>
@@ -841,8 +823,8 @@
{/each}
</div>
<p class="text-xs text-gray-500 mt-3">
Applies to videos started from now on; a video already playing keeps the
quality it started at.
Applies to videos started from now on; a video already playing keeps the quality it
started at.
</p>
</div>
@@ -861,11 +843,10 @@
</span>
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the
built-in web player, for better performance and battery life,
and so picture-in-picture shows the video rather than the app.
On by default. Turn it off to fall back to the built-in web
player if a video misbehaves.
Decode video with the device's hardware decoder instead of the built-in web
player, for better performance and battery life, and so picture-in-picture shows
the video rather than the app. On by default. Turn it off to fall back to the
built-in web player if a video misbehaves.
</p>
</div>
<button
@@ -882,9 +863,7 @@
></span>
</button>
</div>
<p class="text-xs text-gray-500 mt-3">
Takes effect the next time you start a video.
</p>
<p class="text-xs text-gray-500 mt-3">Takes effect the next time you start a video.</p>
</div>
{/if}
</div>
@@ -896,8 +875,8 @@
<div class="bg-[var(--color-surface)] rounded-lg p-6">
<h3 class="text-lg font-semibold text-white mb-1">Result Group Order</h3>
<p class="text-sm text-gray-400 mb-4">
Drag or use the arrows to choose the order search result groups appear in.
Empty groups are hidden automatically.
Drag or use the arrows to choose the order search result groups appear in. Empty groups
are hidden automatically.
</p>
<SearchGroupOrderList />
</div>
@@ -928,7 +907,9 @@
<span class="text-sm text-gray-400">Loading...</span>
{:else if cacheStats}
<span class="text-sm text-gray-300">
{formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0 ? "Unlimited" : formatBytes(cacheStats.limitBytes)}
{formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0
? "Unlimited"
: formatBytes(cacheStats.limitBytes)}
</span>
{/if}
</div>
@@ -937,7 +918,11 @@
<!-- Progress bar -->
<div class="w-full bg-gray-700 rounded-full h-3 mb-2">
<div
class="h-3 rounded-full transition-all duration-300 {getCacheUsagePercent() > 90 ? 'bg-red-500' : getCacheUsagePercent() > 70 ? 'bg-yellow-500' : 'bg-[var(--color-jellyfin)]'}"
class="h-3 rounded-full transition-all duration-300 {getCacheUsagePercent() > 90
? 'bg-red-500'
: getCacheUsagePercent() > 70
? 'bg-yellow-500'
: 'bg-[var(--color-jellyfin)]'}"
style="width: {getCacheUsagePercent()}%"
></div>
</div>
@@ -999,9 +984,7 @@
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
<div class="mb-4">
<h3 class="text-xl font-semibold text-white">Storage Limit</h3>
<p class="text-sm text-gray-400 mt-1">
Maximum storage for offline downloads
</p>
<p class="text-sm text-gray-400 mt-1">Maximum storage for offline downloads</p>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
@@ -1060,8 +1043,7 @@
<div>
<h3 class="text-xl font-semibold text-white">Queue Pre-caching</h3>
<p class="text-sm text-gray-400 mt-1">
Download the next {cacheConfig.queuePrecacheCount} tracks in the queue
automatically
Download the next {cacheConfig.queuePrecacheCount} tracks in the queue automatically
</p>
</div>
<button
@@ -1088,11 +1070,10 @@
<h3 class="text-xl font-semibold text-white">WiFi Only</h3>
<p class="text-sm text-gray-400 mt-1">
{#if networkDetectionSupported}
Hold downloads unless on an unmetered network. Cellular and
metered hotspots are excluded; WiFi and Ethernet are allowed.
Hold downloads unless on an unmetered network. Cellular and metered hotspots are
excluded; WiFi and Ethernet are allowed.
{:else}
Only available on Android — this device has no metered
connection to detect.
Only available on Android — this device has no metered connection to detect.
{/if}
</p>
</div>
@@ -1100,9 +1081,7 @@
onclick={handleWifiOnlyToggle}
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'} {networkDetectionSupported
? ''
: 'opacity-50 cursor-not-allowed'}"
: 'bg-gray-600'} {networkDetectionSupported ? '' : 'opacity-50 cursor-not-allowed'}"
aria-label="Toggle WiFi only downloads"
aria-pressed={cacheConfig.wifiOnly}
disabled={!networkDetectionSupported}
@@ -1135,16 +1114,15 @@
<p class="font-semibold mb-1">About these settings:</p>
<ul class="list-disc list-inside space-y-1 text-blue-200">
<li>
<strong>Crossfade</strong> smoothly blends the end of one track with the
beginning of the next
<strong>Crossfade</strong> smoothly blends the end of one track with the beginning of
the next
</li>
<li>
<strong>Gapless</strong> removes silence between tracks for continuous
album playback
<strong>Gapless</strong> removes silence between tracks for continuous album playback
</li>
<li>
<strong>Normalization</strong> evens out loudness between tracks
in real time, toward your selected level
<strong>Normalization</strong> evens out loudness between tracks in real time, toward
your selected level
</li>
</ul>
</div>