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.
329 lines
12 KiB
Svelte
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>
|