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:
+50
-40
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user