Files
jellytau/src/lib/components/library/GenericMediaListPage.svelte
T
dtourolle 0815445aa7 feat(library): exclude chosen folders from music browsing
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
2026-08-20 20:09:57 +02:00

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>