- music landing: diverse per-genre album sliders (online counts / offline wide-probe fallback) and home-screen library shortcuts - add ArtistLinks component and shared navigation/genreDiversity utils - player/playback-mode refinements across Rust and frontend
266 lines
8.8 KiB
Svelte
266 lines
8.8 KiB
Svelte
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { navigateBack } 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 type { MediaItem, Library, ItemType } from "$lib/api/types";
|
|
import LibraryGrid from "./LibraryGrid.svelte";
|
|
import TrackList from "./TrackList.svelte";
|
|
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
|
|
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
let { config }: Props = $props();
|
|
|
|
let items = $state<MediaItem[]>([]);
|
|
let loading = $state(true);
|
|
let gridWrapper = $state<HTMLDivElement | null>(null);
|
|
let searchQuery = $state("");
|
|
let debouncedSearchQuery = $state("");
|
|
let sortBy = $state<string>("");
|
|
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let initialLoadDone = false;
|
|
|
|
$effect(() => {
|
|
sortBy = config.defaultSort;
|
|
});
|
|
|
|
const { markLoaded } = useServerReachabilityReload(async () => {
|
|
await 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
|
|
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
|
|
if (debouncedSearchQuery.trim()) {
|
|
const result = await repo.search(debouncedSearchQuery, {
|
|
includeItemTypes: [config.itemType],
|
|
limit: 10000,
|
|
});
|
|
items = excludePodcasts(result.items);
|
|
} else {
|
|
const result = await repo.getItems($currentLibrary.id, {
|
|
includeItemTypes: [config.itemType],
|
|
sortBy,
|
|
sortOrder,
|
|
recursive: true,
|
|
limit: 10000,
|
|
});
|
|
items = excludePodcasts(result.items);
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to load ${config.itemType}:`, e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function handleSearch(query: string) {
|
|
searchQuery = query;
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
|
|
function handleSort(newSort: string) {
|
|
sortBy = newSort;
|
|
loadItems();
|
|
}
|
|
|
|
function toggleSortOrder() {
|
|
sortOrder = sortOrder === "Ascending" ? "Descending" : "Ascending";
|
|
loadItems();
|
|
}
|
|
|
|
function goBack() {
|
|
navigateBack(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 -->
|
|
<div class="flex items-center gap-4">
|
|
<BackButton onClick={goBack} label="Back" />
|
|
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|