Files
jellytau/src/routes/library/+page.svelte
T
dtourolle ad48d89dfe 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.
2026-08-21 17:41:44 +02:00

329 lines
12 KiB
Svelte

<script lang="ts">
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 { isServerReachable } from "$lib/stores/connectivity";
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import GenreFilter from "$lib/components/library/GenreFilter.svelte";
import MosaicGrid from "$lib/components/library/MosaicGrid.svelte";
import MosaicTile from "$lib/components/library/MosaicTile.svelte";
import { buildLibraryMosaic } from "$lib/components/library/libraryMosaic";
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
// Search results are rendered exclusively by /search — this page used to
// render them inline, which made the header search bar appear broken on every
// other /library/** route. TRACES: UR-049 | DR-063
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
// Music/TV/Movies libraries have their own dedicated landing pages
// (/library/music, /library/tv, /library/movies). When `currentLibrary` is one
// of those, any inline "library content" view here is a STALE leftover from
// navigating into that page — showing it makes "up"/back from that page render
// the library's item list instead of the libraries overview. Treat those types
// as "no inline content" so this page always shows the overview for them,
// whether we arrived via the header Up affordance or the hardware back button.
// Live TV / channels / other types still render their content inline here.
const currentLibraryHasDedicatedPage = $derived(
$currentLibrary?.collectionType === "music" ||
$currentLibrary?.collectionType === "tvshows" ||
$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");
});
// The overview is a mosaic: rows of one height, tiles of their own widths, so
// a square music cover sits beside a wide backdrop without either being
// cropped to the other's shape. Each category also gets a favourites tile of
// its own, beside the library it belongs to. TRACES: UR-075 | DR-174, DR-175
const mosaicEntries = $derived(buildLibraryMosaic(visibleLibraries));
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
let previousServerReachable = false;
onMount(async () => {
if ($libraries.length === 0) {
await library.loadLibraries();
}
hasLoadedOnce = true;
});
// Reload when server becomes reachable (handles cache-first timing issue)
$effect(() => {
const serverReachable = $isServerReachable;
if (serverReachable && !previousServerReachable && hasLoadedOnce) {
// Reload libraries when server becomes available
library.loadLibraries();
// Also reload current library items if viewing a library
if ($currentLibrary) {
library.loadItems($currentLibrary.id, {
genres: $selectedGenres.length > 0 ? $selectedGenres : undefined,
});
}
}
previousServerReachable = serverReachable;
});
async function handleLibraryClick(lib: Library) {
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
// Route to dedicated music library page
if (lib.collectionType === "music") {
library.setCurrentLibrary(lib);
goto("/library/music");
return;
}
// Route to dedicated TV library landing page
if (lib.collectionType === "tvshows") {
library.setCurrentLibrary(lib);
goto("/library/tv");
return;
}
// Route to dedicated movies library landing page
if (lib.collectionType === "movies") {
library.setCurrentLibrary(lib);
goto("/library/movies");
return;
}
// Live TV channels use a dedicated endpoint
if (lib.collectionType === "livetv") {
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadLiveTvChannels();
return;
}
// Plugin "Channels" root list uses a dedicated endpoint
if (lib.collectionType === "channels") {
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadChannels();
return;
}
// For other library types, load items normally
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadItems(lib.id);
}
async function handleGenreFilterChange() {
if ($currentLibrary) {
await library.loadItems($currentLibrary.id, {
genres: $selectedGenres.length > 0 ? $selectedGenres : undefined,
});
}
}
function handleItemClick(item: MediaItem | Library) {
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
if ("kind" in item) {
// It's a MediaItem
const mediaItem = item as MediaItem;
// A playable channel leaf plays directly; a channel container drills in.
if (mediaItem.kind === "channelItem") {
goto(`/player/${mediaItem.id}`);
return;
}
switch (mediaItem.kind) {
case "series":
case "movie":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
// Navigate to detail view
goto(`/library/${mediaItem.id}`);
break;
case "episode":
// Episodes play directly
goto(`/player/${mediaItem.id}`);
break;
default:
// For other items, try detail page first
goto(`/library/${mediaItem.id}`);
break;
}
} else {
// It's a Library
handleLibraryClick(item as Library);
}
}
function goBackToLibraries() {
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
library.setCurrentLibrary(null);
}
</script>
<div class="space-y-8">
{#if showInlineLibraryContent}
<!-- Library content (live TV / channels / other inline-rendered types) -->
<div class="space-y-6">
<div class="flex items-center gap-4">
<button
onclick={goBackToLibraries}
class="text-gray-400 hover:text-white transition-colors"
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"
/>
</svg>
</button>
<h1 class="text-2xl font-bold text-white">{$currentLibrary?.name}</h1>
</div>
{#if isMusicLibrary}
<GenreFilter onFilterChange={handleGenreFilterChange} />
{/if}
<LibraryGrid
items={$libraryItems}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
</div>
{:else}
<!-- Libraries overview -->
<div>
<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
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>
</div>
</div>
{#if $isLibraryLoading}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#each Array(6) as _}
<div class="animate-pulse">
<div class="aspect-video bg-[var(--color-surface)] rounded-lg"></div>
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
</div>
{/each}
</div>
{:else if visibleLibraries.length === 0}
<div class="text-center py-12 text-gray-400">
<p>No libraries found</p>
</div>
{:else}
<!-- Favourites are destinations in their own right, not just the icon in
the header above. The cross-library entry leads the mosaic and each
category's own favourites sits beside its library — a labelled tile
at the same weight as a library is the difference between a feature
people find and one they don't. ux-flows §5C.2.
TRACES: UR-067, UR-075 | DR-117, DR-174 -->
<MosaicGrid items={mosaicEntries} gap={8}>
{#snippet tile(entry)}
{#if entry.kind === "favorites"}
<MosaicTile
label={entry.label}
width={entry.width}
height={entry.height}
accent
onclick={() => goto(entry.href)}
>
{#snippet icon()}
<svg
class="w-10 h-10 text-[var(--color-jellyfin)]"
fill="currentColor"
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"
/>
</svg>
{/snippet}
</MosaicTile>
{:else}
<MosaicTile
label={entry.label}
width={entry.width}
height={entry.height}
itemId={entry.library.id}
imageTag={entry.library.imageTag}
onRatio={entry.reportRatio}
onclick={() => handleLibraryClick(entry.library)}
/>
{/if}
{/snippet}
</MosaicGrid>
{/if}
</div>
{/if}
</div>