Files
jellytau/src/lib/components/library/GenericMediaListPage.svelte
T
dtourolle 1b70926c36
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
2026-08-09 16:38:07 +02:00

362 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 { 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;
/**
* 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 = excludePodcasts(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
// 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,
// 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 = excludePodcasts(result.items);
}
} catch (e) {
console.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>