🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m57s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m13s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Build & Release / Build Linux (push) Successful in 17m52s
Build & Release / Build Android (push) Failing after 58s
Build & Release / Create Release (push) Has been skipped
Navigation: - Split conflated "back" into navigateUp (deterministic route parent) and a history-safe navigateBack that tracks in-app depth via afterNavigate instead of history.length. Fixes the resume-from-background trap where a stale WebView stack left the header arrow stuck on the current page. - /library self-corrects for music/tv/movies (which have dedicated landing pages): a leftover currentLibrary no longer forces the inline content-list view, so "up"/back shows the libraries overview. Live TV / channels / other types still render inline. Startup (unblock first paint): - auth.initialize() no longer awaits security-status, player-config, or session verification before flipping isInitialized. These run fire-and-forget after the session is restored, so the library overview paints without waiting on several serial IPC round-trips. Versioning / CI: - tauri.conf.json + package.json aligned to 0.0.15 (the tag series had drifted to 0.1.0, whose formula-derived versionCode 1000 outran the v0.0.x tags). - Release workflow now pins a monotonic Android versionCode (1000 + major*10000 + minor*100 + patch) so tagged builds never downgrade below prior installs and always increase in semver order. Tests: navigation (4), auth (29), playbackMode (23) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
311 lines
10 KiB
Svelte
311 lines
10 KiB
Svelte
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
|
|
<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 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 { 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;
|
|
|
|
/**
|
|
* 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 = excludePodcasts(result.items);
|
|
});
|
|
}
|
|
|
|
$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()) {
|
|
// 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 = excludePodcasts(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,
|
|
});
|
|
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);
|
|
});
|
|
|
|
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 -->
|
|
<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>
|