Replaces a hardcoded filter that dropped anything named "Podcasts" from music results — one user's library layout compiled into the shipped product, keyed on an English literal, applied only at the six call sites someone had remembered. Exclusion is now a user setting stored in Rust and applied at the repository layer's convergence points, so scope is decided once and is the same on every screen. It matches on folder id rather than name: a title is not what an item is, which is why an album legitimately called "Podcasts" used to vanish. Deliberately not filtered: get_item (an id asked for by name was navigated to on purpose, and refusing it would break playback of anything inside a hidden folder), get_downloaded_items (hiding a download would leave the user unable to delete a file whose disk usage they can still see), and the offline cache (an exclusion is a view preference and must be reversible without a re-crawl). Also removes src/lib/utils/validation.ts — six exported validators with no caller outside their own test file, which made the module read as covered input validation while guarding nothing. TRACES: UR-076 | DR-209 | UT-203
365 lines
13 KiB
Svelte
365 lines
13 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 { shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
|
import { isAndroid } from "$lib/stores/appState";
|
|
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" });
|
|
}
|
|
|
|
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
|
|
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
|
|
// whichever bars are visible.
|
|
const bottomGap = $derived(
|
|
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
|
|
);
|
|
</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={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={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} {bottomGap} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|