Files
jellytau/src/lib/components/library/GenericGenreBrowser.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

276 lines
9.4 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
/**
* Generic genre browser supporting Movies, Music Albums, and TV Series
* Consolidates duplicate genre-browsing logic across media types
*
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens (genre filtering)
*/
export interface GenreConfig {
itemTypes: ItemType[]; // ["Movie"] or ["MusicAlbum"] or ["Series"]
title: string; // "Movie Genres" or "Genres" or "TV Genres"
backPath: string; // "/library" or "/library/music"
genreIcon: string; // SVG path for genre icon
itemDisplayMode: "poster" | "square"; // Aspect ratio: 2/3 or 1/1
searchPlaceholder?: string; // Optional custom placeholder
noItemsMessage?: string; // Optional custom empty state
}
interface Props {
config: GenreConfig;
/**
* Suppress the back button + title when this renders as a *tab* of a
* library page that already has a header. Drilling into a single genre
* still shows the header — there the back button is the way out.
* TRACES: UR-063 | DR-105
*/
showHeader?: boolean;
}
let { config, showHeader = true }: Props = $props();
let genres = $state<Genre[]>([]);
let filteredGenres = $state<Genre[]>([]);
let loading = $state(true);
let searchQuery = $state("");
let selectedGenre = $state<Genre | null>(null);
let genreItems = $state<MediaItem[]>([]);
let loadingItems = $state(false);
async function reloadGenreBrowse() {
await loadGenres();
if (selectedGenre) {
await loadGenreItems(selectedGenre);
}
}
const { markLoaded } = useServerReachabilityReload(reloadGenreBrowse);
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
useOfflineFilterReload(reloadGenreBrowse);
onMount(async () => {
await loadGenres();
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
const requestedGenre = $page.url.searchParams.get("genre");
if (requestedGenre) {
const match = genres.find(
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
);
if (match) {
await loadGenreItems(match);
}
}
markLoaded();
});
async function loadGenres() {
if (!$currentLibrary) {
goto(config.backPath);
return;
}
try {
loading = true;
const repo = auth.getRepository();
const result = await repo.getGenres($currentLibrary.id);
genres = result.sort((a, b) => a.name.localeCompare(b.name));
applyFilter();
} catch (e) {
console.error("Failed to load genres:", e);
} finally {
loading = false;
}
}
async function loadGenreItems(genre: Genre) {
if (!$currentLibrary) return;
try {
loadingItems = true;
selectedGenre = genre;
const repo = auth.getRepository();
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: config.itemTypes,
genres: [genre.name],
sortBy: "SortName",
sortOrder: "Ascending",
recursive: true,
limit: 10000,
});
genreItems = result.items;
} catch (e) {
console.error("Failed to load genre items:", e);
} finally {
loadingItems = false;
}
}
function applyFilter() {
let result = [...genres];
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter((genre) => genre.name.toLowerCase().includes(query));
}
filteredGenres = result;
}
function handleSearch(query: string) {
searchQuery = query;
applyFilter();
}
function handleGenreClick(genre: Genre) {
loadGenreItems(genre);
}
function handleItemClick(item: MediaItem) {
goto(`/library/${item.id}`);
}
function goBack() {
if (selectedGenre) {
selectedGenre = null;
genreItems = [];
} else {
navigateUp(config.backPath);
}
}
const aspectRatioClass = $derived(config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square");
const gridColsClass = $derived(
config.itemDisplayMode === "poster"
? "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
);
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
const noItemsMessage = $derived(config.noItemsMessage || `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`);
</script>
<div class="space-y-6">
<!-- Header. Inside a genre the back button is the only way out, so it shows
even when the host page suppresses the top-level header. -->
{#if showHeader || selectedGenre}
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">
{#if selectedGenre}
{selectedGenre.name}
{:else}
{config.title}
{/if}
</h1>
</div>
{/if}
{#if !selectedGenre}
<!-- Genre Browser -->
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
{#if !loading && filteredGenres.length > 0}
<ResultsCounter count={filteredGenres.length} itemType="genre" searchQuery={searchQuery} />
{/if}
<!-- Genres Grid -->
{#if loading}
<div class="grid {gridColsClass} gap-4">
{#each Array(12) 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 if filteredGenres.length === 0}
<div class="text-center py-12 text-gray-400">
<p>No genres found</p>
</div>
{:else}
<div class="grid {gridColsClass} gap-4">
{#each filteredGenres as genre (genre.id)}
<button onclick={() => handleGenreClick(genre)} class="group text-left">
<div
class="aspect-square bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-[var(--color-jellyfin)]/5 rounded-lg flex items-center justify-center group-hover:from-[var(--color-jellyfin)]/30 group-hover:to-[var(--color-jellyfin)]/10 transition-all"
>
<svg
class="w-12 h-12 text-[var(--color-jellyfin)] opacity-70 group-hover:opacity-100 transition-opacity"
fill="currentColor"
viewBox="0 0 24 24"
>
{@html config.genreIcon}
</svg>
</div>
<p class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{genre.name}
</p>
</button>
{/each}
</div>
{/if}
{:else}
<!-- Genre Items View -->
{#if loadingItems}
<div class="grid {gridColsClass} gap-4">
{#each Array(10) as _}
<div class="animate-pulse">
<div class="{aspectRatioClass} 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 if genreItems.length === 0}
<div class="text-center py-12 text-gray-400">
<p>{noItemsMessage}</p>
</div>
{:else}
<div>
<ResultsCounter count={genreItems.length} itemType={config.itemTypes[0]?.toLowerCase() || "item"} />
<div class="grid {gridColsClass} gap-4 mt-4">
{#each genreItems as item (item.id)}
<button onclick={() => handleItemClick(item)} class="group text-left">
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.imageId}
maxWidth={300}
alt={item.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
</div>
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{truncateMiddle(item.name, 40)}
</p>
{#if item.productionYear}
<p class="text-sm text-gray-400">
{item.productionYear}
{#if item.communityRating}
<span class="text-yellow-500 ml-1">{item.communityRating.toFixed(1)}</span>
{/if}
</p>
{/if}
</button>
{/each}
</div>
</div>
{/if}
{/if}
</div>