The last few letters of the A-Z index sat behind the mini player and bottom
nav, where they could not be tapped — most visible on an album's track list or
the music library, since that is where audio is usually already playing.
AlphabetScrollBar sized itself as window.innerHeight minus a hardcoded
bottomGap: 5rem, 7rem or 11rem, chosen by platform and by whether the mini
player was showing. That arithmetic dates from when the mini player and bottom
nav were position: fixed overlays. They have been in-flow flex siblings below
the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is*
the top of the mini player and can simply be measured.
The guess was also short on every device with a navigation or gesture bar,
because --safe-bottom is padded inside BottomUi (DR-112) and no guess knew
about it. Measured against a 800px viewport: the strip overran the scrollport
by ~45px with the nav alone, ~18px with the mini player and ~50px in remote
mode, burying one to three letters.
The floor is now the nearest scrollable ancestor's bottom edge. That ancestor
is resolved by computed overflow-y rather than closest("main"): the root shell
scrolls in a plain div, and a miss fell back to the viewport silently, which
would reinstate the bug on any route outside /library. Observing that scroller
for resize is also what re-measures when the mini player appears, so the
component no longer subscribes to player or platform stores at all.
The floor rule is extracted to alphabetStrip.ts so it can be tested; the three
overlap cases above fail against the old arithmetic and pass against the new.
TRACES: UR-007 | DR-262 | UT-235, UT-236, UT-237
365 lines
12 KiB
Svelte
365 lines
12 KiB
Svelte
<!-- TRACES: UR-007, UR-029, UR-030, UR-067 | DR-007, DR-032, DR-033, DR-116 -->
|
|
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
import { goto } from "$app/navigation";
|
|
import { navigateUp } from "$lib/utils/navigation";
|
|
import { currentLibrary } from "$lib/stores/library";
|
|
import { auth } from "$lib/stores/auth";
|
|
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
|
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
|
|
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
|
|
import BackButton from "$lib/components/common/BackButton.svelte";
|
|
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
|
import type { MediaItem, Library, ItemType, SearchResult } from "$lib/api/types";
|
|
import LibraryGrid from "./LibraryGrid.svelte";
|
|
import TrackList from "./TrackList.svelte";
|
|
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("GenericMediaListPage");
|
|
|
|
/**
|
|
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
|
* Consolidates duplicate music library browsing logic
|
|
*
|
|
* @req: UR-007 - Navigate media in library
|
|
* @req: UR-008 - Search media across libraries
|
|
* @req: DR-007 - Library browsing screens
|
|
*/
|
|
|
|
export interface MediaListConfig {
|
|
itemType: ItemType; // "MusicAlbum", "MusicArtist", "Playlist", "Audio"
|
|
title: string; // "Albums", "Artists", "Playlists", "Tracks"
|
|
backPath: string; // "/library/music"
|
|
searchPlaceholder?: string;
|
|
sortOptions: Array<{ key: string; label: string }>; // Jellyfin field names
|
|
defaultSort: string; // Jellyfin field name (e.g., "SortName")
|
|
displayComponent: "grid" | "tracklist"; // Which component to use
|
|
}
|
|
|
|
interface Props {
|
|
config: MediaListConfig;
|
|
/**
|
|
* Suppress the back button + title. Set when this renders as a *tab* of a
|
|
* library page, which already has its own header — two stacked headers and
|
|
* two back buttons read as two pages. TRACES: UR-063 | DR-105
|
|
*/
|
|
showHeader?: boolean;
|
|
}
|
|
|
|
let { config, showHeader = true }: Props = $props();
|
|
|
|
let items = $state<MediaItem[]>([]);
|
|
let loading = $state(true);
|
|
let gridWrapper = $state<HTMLDivElement | null>(null);
|
|
let searchQuery = $state("");
|
|
let debouncedSearchQuery = $state("");
|
|
let favoritesOnly = $state(false);
|
|
let sortBy = $state<string>("");
|
|
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let initialLoadDone = false;
|
|
|
|
/**
|
|
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
|
|
* `repo.search()` resolves instantly with cache-only (downloaded) results;
|
|
* the merged cache+server union arrives later via this event.
|
|
*/
|
|
interface SearchUpdateEvent {
|
|
requestId: number;
|
|
result: SearchResult;
|
|
}
|
|
|
|
// Monotonic id identifying the latest search request. The deferred
|
|
// `search-event` is only applied when its requestId still matches, so
|
|
// out-of-order / superseded server results never clobber fresher ones.
|
|
let searchRequestId = 0;
|
|
let unlistenSearch: UnlistenFn | null = null;
|
|
|
|
async function ensureSearchListener() {
|
|
if (unlistenSearch) return;
|
|
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
|
|
const { requestId, result } = event.payload;
|
|
if (requestId !== searchRequestId) return;
|
|
items = result.items;
|
|
});
|
|
}
|
|
|
|
$effect(() => {
|
|
sortBy = config.defaultSort;
|
|
});
|
|
|
|
const { markLoaded } = useServerReachabilityReload(async () => {
|
|
await loadItems();
|
|
});
|
|
|
|
// Re-query when the offline downloaded-only gate changes — going offline, or
|
|
// toggling "Show all server media". Without this the listing kept whatever it
|
|
// was first loaded with and the toggle only greyed cards. TRACES: UR-052 | DR-143
|
|
useOfflineFilterReload(() => loadItems());
|
|
|
|
onMount(async () => {
|
|
await loadItems();
|
|
markLoaded();
|
|
initialLoadDone = true;
|
|
});
|
|
|
|
async function loadItems() {
|
|
if (!$currentLibrary) {
|
|
goto(config.backPath);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Only show skeleton on first load (no data yet)
|
|
if (items.length === 0) loading = true;
|
|
const repo = auth.getRepository();
|
|
|
|
// Use backend search if search query is provided, otherwise use getItems
|
|
// with sort. Neither result is filtered here: folders the user chose to
|
|
// hide are dropped by the repository layer. TRACES: UR-076 | DR-209
|
|
if (debouncedSearchQuery.trim()) {
|
|
// Phase 1: instant cache-only (downloaded) results. The merged
|
|
// cache+server union arrives later via the `search-event` listener,
|
|
// tagged with this requestId so superseded queries are ignored.
|
|
await ensureSearchListener();
|
|
const requestId = ++searchRequestId;
|
|
const result = await repo.search(
|
|
debouncedSearchQuery,
|
|
{
|
|
includeItemTypes: [config.itemType],
|
|
limit: 10000,
|
|
},
|
|
requestId,
|
|
);
|
|
// Only apply if this is still the active query.
|
|
if (requestId === searchRequestId) {
|
|
items = result.items;
|
|
}
|
|
} else {
|
|
// Leaving search — invalidate any in-flight server results.
|
|
searchRequestId++;
|
|
const result = await repo.getItems($currentLibrary.id, {
|
|
includeItemTypes: [config.itemType],
|
|
sortBy,
|
|
sortOrder,
|
|
recursive: true,
|
|
limit: 10000,
|
|
// Narrows the listing in place; the backend owns what "favourite"
|
|
// resolves to online vs offline. TRACES: UR-067 | DR-116
|
|
favoritesOnly: favoritesOnly ? true : undefined,
|
|
});
|
|
items = result.items;
|
|
}
|
|
} catch (e) {
|
|
log.error(`Failed to load ${config.itemType}:`, e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function handleSearch(query: string) {
|
|
searchQuery = query;
|
|
}
|
|
|
|
/// TRACES: UR-067 | DR-116
|
|
function toggleFavoritesOnly() {
|
|
favoritesOnly = !favoritesOnly;
|
|
loadItems();
|
|
}
|
|
|
|
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
|
|
$effect(() => {
|
|
const _query = searchQuery; // track for reactivity
|
|
if (!initialLoadDone) return;
|
|
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
debouncedSearchQuery = searchQuery;
|
|
loadItems();
|
|
}, 300);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (unlistenSearch) unlistenSearch();
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
});
|
|
|
|
function handleSort(newSort: string) {
|
|
sortBy = newSort;
|
|
loadItems();
|
|
}
|
|
|
|
function toggleSortOrder() {
|
|
sortOrder = sortOrder === "Ascending" ? "Descending" : "Ascending";
|
|
loadItems();
|
|
}
|
|
|
|
function goBack() {
|
|
navigateUp(config.backPath);
|
|
}
|
|
|
|
const searchPlaceholder = $derived(
|
|
config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`,
|
|
);
|
|
|
|
function handleItemClick(item: MediaItem | Library) {
|
|
// Navigate to detail page for browseable items
|
|
goto(`/library/${item.id}`);
|
|
}
|
|
|
|
function handleTrackClick(track: MediaItem, _index: number) {
|
|
// For track lists, navigate to the track's album if available, otherwise detail page
|
|
if (track.albumId) {
|
|
goto(`/library/${track.albumId}`);
|
|
} else {
|
|
goto(`/library/${track.id}`);
|
|
}
|
|
}
|
|
|
|
// ===== A-Z jump bar =====
|
|
// Bucket a name to its index letter: A-Z, or "#" for digits/symbols/empty.
|
|
function letterFor(name: string): string {
|
|
const first = (name ?? "").trim().charAt(0).toUpperCase();
|
|
return first >= "A" && first <= "Z" ? first : "#";
|
|
}
|
|
|
|
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
|
|
const isAlphaSorted = $derived(sortBy === "SortName");
|
|
const showAlphaBar = $derived(
|
|
isAlphaSorted && !loading && !debouncedSearchQuery.trim() && items.length > 30,
|
|
);
|
|
|
|
const availableLetters = $derived.by(() => {
|
|
const set = new Set<string>();
|
|
if (showAlphaBar) {
|
|
for (const item of items) set.add(letterFor(item.name));
|
|
}
|
|
return set;
|
|
});
|
|
|
|
// First item index for each letter, honouring current ascending/descending order.
|
|
const firstIndexForLetter = $derived.by(() => {
|
|
const map = new Map<string, number>();
|
|
items.forEach((item, index) => {
|
|
const letter = letterFor(item.name);
|
|
if (!map.has(letter)) map.set(letter, index);
|
|
});
|
|
return map;
|
|
});
|
|
|
|
function jumpToLetter(letter: string) {
|
|
const index = firstIndexForLetter.get(letter);
|
|
if (index === undefined || !gridWrapper) return;
|
|
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
|
|
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Header -->
|
|
{#if showHeader}
|
|
<div class="flex items-center gap-4">
|
|
<BackButton onClick={goBack} label="Back" />
|
|
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Search and Sort Bar -->
|
|
<div class="flex flex-col sm:flex-row gap-4">
|
|
<!-- Search -->
|
|
<div class="flex-1">
|
|
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
|
</div>
|
|
|
|
<!-- Favourites filter. Session-scoped on purpose: a persisted filter that
|
|
hides most of a library reads as data loss on the next launch
|
|
(ux-flows §5C.2). Hidden while searching, which has no favourites
|
|
filter of its own. TRACES: UR-067 | DR-116 -->
|
|
{#if !debouncedSearchQuery.trim()}
|
|
<button
|
|
onclick={toggleFavoritesOnly}
|
|
aria-pressed={favoritesOnly}
|
|
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
|
|
{favoritesOnly
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
|
|
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
fill={favoritesOnly ? "currentColor" : "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>
|
|
Favourites
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Sort (only show if there are sort options) -->
|
|
{#if config.sortOptions.length > 0}
|
|
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Results Count -->
|
|
{#if !loading}
|
|
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} {searchQuery} />
|
|
{/if}
|
|
|
|
<!-- Items List/Grid -->
|
|
{#if loading}
|
|
{#if config.displayComponent === "grid"}
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{#each Array(10) as _}
|
|
<div class="animate-pulse">
|
|
<div class="aspect-square 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}
|
|
<div class="space-y-2">
|
|
{#each Array(5) as _}
|
|
<div class="animate-pulse h-16 bg-[var(--color-surface)] rounded-lg"></div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{:else if items.length === 0}
|
|
<div class="text-center py-12 text-gray-400">
|
|
<p>No {config.title.toLowerCase()} found</p>
|
|
</div>
|
|
{:else}
|
|
<div class="flex gap-2">
|
|
<div bind:this={gridWrapper} class="flex-1 min-w-0">
|
|
{#if config.displayComponent === "grid"}
|
|
<LibraryGrid
|
|
{items}
|
|
onItemClick={handleItemClick}
|
|
musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(
|
|
config.itemType,
|
|
)}
|
|
/>
|
|
{:else if config.displayComponent === "tracklist"}
|
|
<TrackList tracks={items} onTrackClick={handleTrackClick} />
|
|
{/if}
|
|
</div>
|
|
{#if showAlphaBar}
|
|
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
|
|
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|