feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s

Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
  horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
  listened to in a while") albums via a new repository method across
  online/offline/hybrid repos plus the repository_get_rediscover_albums
  command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
  index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.

Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
  persist the resolved stream URL + target dir on each row (migration
  017), and the pump starts up to max_concurrent and drains the rest
  automatically as slots free, instead of the frontend silently dropping
  items past the concurrency limit. Album/series/season buttons now
  enqueue rather than calling start_download directly.

Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
  cache+server union via a request-id-tagged search-event, so superseded
  queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
This commit is contained in:
2026-06-24 20:44:17 +02:00
parent dcf08f30bc
commit 17a35573a0
33 changed files with 2045 additions and 188 deletions
+33 -2
View File
@@ -726,6 +726,31 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
async startDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("start_download", { downloadId, streamUrl, targetDir });
},
/**
* Enqueue a download with its resolved stream URL, then let the queue pump
* start it (or a higher-priority pending item) when a slot is free.
*
* Unlike [`start_download`], this never errors when the concurrency limit is
* reached: the URL is persisted on the row and the pump will pick it up once a
* slot frees. This is the path bulk operations (album/series/season) use so
* every queued item eventually downloads without the frontend re-issuing it.
*/
async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_download", { downloadId, streamUrl, targetDir });
},
/**
* Enqueue a batch of already-queued video downloads, resolving each one's
* transcode URL from the repository using the `quality_preset` stored on the
* row. Then let the pump start them subject to the concurrency limit.
*
* This is the bulk video path (series/season): `download_series`/
* `download_season` insert the rows, then this resolves URLs and enqueues them
* so they actually start. Resolving server-side avoids round-tripping every
* episode URL through the frontend.
*/
async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir });
},
/**
* Get download manager statistics
*/
@@ -1037,6 +1062,12 @@ async repositoryGetRecentlyPlayedAudio(handle: string, limit: number | null) : P
async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_resume_movies", { handle, limit });
},
/**
* Get albums the user hasn't listened to recently ("rediscover")
*/
async repositoryGetRediscoverAlbums(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_rediscover_albums", { handle, parentId, limit });
},
/**
* Get genres for a library
*/
@@ -1046,8 +1077,8 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
/**
* Search for items
*/
async repositorySearch(handle: string, query: string, options: SearchOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options });
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
},
/**
* Get playback info for an item
+1
View File
@@ -334,6 +334,7 @@ describe("RepositoryClient", () => {
includeItemTypes: ["Audio"],
limit: 100,
},
requestId: 0,
});
});
});
+7 -2
View File
@@ -109,12 +109,17 @@ export class RepositoryClient {
return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null);
}
/** Albums the user has played but not listened to recently ("rediscover"). */
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null);
}
async getGenres(parentId?: string): Promise<Genre[]> {
return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
}
async search(query: string, options?: SearchOptions): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null);
async search(query: string, options?: SearchOptions, requestId = 0): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId);
}
// ===== Playback Methods (all via Rust) =====
@@ -91,15 +91,18 @@
// Get target directory for downloads
const targetDir = await commands.storageGetPath();
// Start each queued track download
// Enqueue each track with its resolved stream URL. The backend queue
// pump starts up to max_concurrent at a time and advances through the
// rest automatically as slots free up — so we never hit (and silently
// drop) the concurrency limit the way startDownload did.
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) {
await commands.startDownload(downloadIds[i], streamUrl, targetDir);
await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
}
} catch (e) {
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e);
}
}
@@ -0,0 +1,123 @@
<!-- TRACES: UR-007 | DR-007 -->
<script lang="ts">
/**
* A vertical A-Z index strip for long, alphabetically-sorted lists.
* Tapping or dragging a letter jumps to the first item that starts with it.
*
* The parent owns the actual scrolling: it passes `availableLetters`
* (which letters have items) and an `onJump(letter)` callback.
*/
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const HASH = "#"; // bucket for names starting with a digit/symbol
interface Props {
/** Uppercase letters (or "#") that currently have at least one item. */
availableLetters: Set<string>;
/** Called with the chosen letter when the user picks one. */
onJump: (letter: string) => void;
/**
* CSS length reserved at the bottom of the viewport for the bottom nav /
* mini-player bars. The strip stretches to fill the space between the top
* sticky offset and this gap, so it ends just above those bars.
*/
bottomGap?: string;
}
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
// The strip stretches to fill the height of its scroll container (the page's
// <main>, which sits below the sticky header) from the sticky top offset down
// to the bottom gap reserved for the nav / mini-player bars. We measure the
// container's visible height live so it stays correct regardless of header
// size, platform, or window resizes.
let container = $state<HTMLDivElement | null>(null);
let viewportHeight = $state(0);
function measure() {
const scroller = container?.closest("main") ?? document.documentElement;
viewportHeight = scroller.clientHeight;
}
$effect(() => {
measure();
const scroller = container?.closest("main");
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
};
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
function jumpTo(letter: string) {
if (!availableLetters.has(letter)) return;
activeLetter = letter;
onJump(letter);
}
// Allow dragging a finger/mouse down the strip to scrub through letters.
function letterAt(clientY: number): string | null {
const el = document.elementFromPoint(
// x is supplied by the caller via the bound container; we read from the
// element under the pointer instead to stay layout-agnostic.
lastClientX,
clientY
);
const letter = el?.getAttribute?.("data-letter");
return letter ?? null;
}
let lastClientX = $state(0);
let isScrubbing = $state(false);
function handlePointerDown(e: PointerEvent) {
isScrubbing = true;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter) jumpTo(letter);
}
function handlePointerMove(e: PointerEvent) {
if (!isScrubbing) return;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter && letter !== activeLetter) jumpTo(letter);
}
function handlePointerUp() {
isScrubbing = false;
}
</script>
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
<div
bind:this={container}
class="flex flex-col items-center justify-between select-none touch-none py-1"
style="height: calc({viewportHeight}px - 1.5rem - {bottomGap})"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="navigation"
aria-label="Jump to letter"
>
{#each letters as letter (letter)}
{@const enabled = availableLetters.has(letter)}
<button
type="button"
data-letter={letter}
disabled={!enabled}
onclick={() => jumpTo(letter)}
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
aria-label={`Jump to ${letter}`}
>
{letter}
</button>
{/each}
</div>
@@ -4,6 +4,8 @@
import { goto } from "$app/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";
@@ -13,6 +15,8 @@
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
@@ -41,6 +45,7 @@
let items = $state<MediaItem[]>([]);
let loading = $state(true);
let gridWrapper = $state<HTMLDivElement | null>(null);
let searchQuery = $state("");
let debouncedSearchQuery = $state("");
let sortBy = $state<string>("");
@@ -74,12 +79,13 @@
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 = result.items;
items = excludePodcasts(result.items);
} else {
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: [config.itemType],
@@ -88,7 +94,7 @@
recursive: true,
limit: 10000,
});
items = result.items;
items = excludePodcasts(result.items);
}
} catch (e) {
console.error(`Failed to load ${config.itemType}:`, e);
@@ -142,6 +148,54 @@
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">
@@ -192,10 +246,19 @@
<p>No {config.title.toLowerCase()} found</p>
</div>
{:else}
{#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 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>
@@ -69,12 +69,14 @@
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each items as item (item.id)}
<MediaCard
{item}
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{#each items as item, index (item.id)}
<div data-grid-index={index}>
<MediaCard
{item}
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
</div>
{/each}
</div>
{/if}
@@ -68,6 +68,7 @@
<button
type="button"
data-grid-index={index}
onclick={() => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
>
@@ -71,6 +71,13 @@
// Pin the season item
await downloads.pinItem(seasonId);
// Resolve each episode's transcode URL (server-side) and enqueue. The
// backend pump then starts up to max_concurrent and advances through the
// rest as slots free up.
const handle = auth.getRepository().getHandle();
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
console.log(" Episodes enqueued; backend pump will start them");
} catch (error) {
console.error("Failed to start season download:", error);
} finally {
@@ -64,9 +64,12 @@
// Pin the series item
await downloads.pinItem(seriesId);
// Start downloads (the backend will handle queuing)
// For now, we'll rely on a download manager to pick them up
// TODO: Implement batch download start
// Resolve each episode's transcode URL (server-side) and enqueue. The
// backend pump then starts up to max_concurrent and advances through the
// rest as slots free up.
const handle = auth.getRepository().getHandle();
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
console.log(" Episodes enqueued; backend pump will start them");
} catch (error) {
console.error("Failed to start series download:", error);
} finally {
+1 -1
View File
@@ -204,7 +204,7 @@
<!-- Track Rows -->
<div class="space-y-1">
{#each tracks as track, index (track.id)}
<div class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
<div data-grid-index={index} class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
<!-- Desktop View -->
<button
onclick={() => handleTrackClick(track, index)}
+51 -8
View File
@@ -2,9 +2,19 @@
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import { auth } from "./auth";
/**
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
* Carries the merged cache+server results for a given search request.
*/
interface SearchUpdateEvent {
requestId: number;
result: SearchResult;
}
export type ViewMode = "grid" | "list";
interface LibraryState {
@@ -47,8 +57,23 @@ function createLibraryStore() {
const { subscribe, set, update } = writable<LibraryState>(initialState);
// Test log to confirm cache logging is active
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
// Monotonic id identifying the most recent search request. Each new search
// bumps it; the deferred `search-event` (carrying merged cache+server
// results) is only applied when its requestId still matches the latest one,
// so out-of-order / superseded results never clobber fresher ones.
let searchRequestId = 0;
let unlistenSearch: UnlistenFn | null = null;
// Lazily subscribe to backend search updates the first time we search.
async function ensureSearchListener() {
if (unlistenSearch) return;
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
const { requestId, result } = event.payload;
// Ignore results from a query the user has already moved on from.
if (requestId !== searchRequestId) return;
update((s) => ({ ...s, searchResults: result.items }));
});
}
async function loadLibraries() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -157,11 +182,17 @@ function createLibraryStore() {
}
async function search(query: string) {
// Bump the request id for every call (including clears) so any in-flight
// backend update for a previous query is ignored when it arrives.
const requestId = ++searchRequestId;
if (!query.trim()) {
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
return;
}
await ensureSearchListener();
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
try {
@@ -172,16 +203,21 @@ function createLibraryStore() {
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
);
// Phase 1: the command resolves with instant local-cache results. The
// merged (cache + server) union arrives later via the `search-event`
// listener above, tagged with this same requestId.
const result = await Promise.race([
repo.search(query, { limit: 10000 }),
repo.search(query, { limit: 10000 }, requestId),
timeoutPromise
]);
update((s) => ({
...s,
searchResults: result.items,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
// Only apply if this is still the active query (a newer search may have
// started while we awaited).
if (requestId === searchRequestId) {
update((s) => ({ ...s, searchResults: result.items }));
}
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1) }));
return result;
} catch (error) {
@@ -196,6 +232,8 @@ function createLibraryStore() {
}
function clearSearch() {
// Invalidate any in-flight backend search update.
searchRequestId++;
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
}
@@ -247,6 +285,11 @@ function createLibraryStore() {
}
function reset() {
searchRequestId++;
if (unlistenSearch) {
unlistenSearch();
unlistenSearch = null;
}
set(initialState);
}
+161
View File
@@ -0,0 +1,161 @@
// Movies library landing page data store.
// Powers the focused movies landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
/** A single "by genre" row: the genre name plus the movies in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface MoviesState {
// Movies the user can resume (continue watching).
continueWatching: MediaItem[];
// Recently added movies in the library.
recentlyAdded: MediaItem[];
// One slider per genre (top genres by movie count).
genreRows: GenreRow[];
// Mix used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
const MAX_GENRES_PROBED = 20;
function createMoviesStore() {
const initialState: MoviesState = {
continueWatching: [],
recentlyAdded: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<MoviesState>(initialState);
/**
* Build the hero rotation. Prefer in-progress movies (most personal), then
* fall back to recently added. De-duplicates by id and prefers items that
* carry backdrop/primary artwork for a good banner.
*/
function buildHero(continueWatching: MediaItem[], recentlyAdded: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [resume, latest] = await Promise.all([
repo.getResumeMovies(SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
]);
const heroItems = buildHero(resume, latest);
update(s => ({
...s,
continueWatching: resume,
recentlyAdded: latest,
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load movie sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load movie sections:", error);
}
}
/**
* Build one slider per genre, showing the top-rated movies in each. We probe
* a bounded set of genres in parallel, drop empty ones, then keep the genres
* with the most movies (so niche/near-empty genres don't crowd the page).
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
const probed = genres.slice(0, MAX_GENRES_PROBED);
const rows = await Promise.all(
probed.map(async (genre): Promise<GenreRow> => {
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["Movie"],
genres: [genre.name],
sortBy: "CommunityRating",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
});
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
})
);
const genreRows = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length)
.slice(0, MAX_GENRE_ROWS);
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load movie genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const movies = createMoviesStore();
export const moviesHeroItems = derived(movies, $m => $m.heroItems);
export const isMoviesLoading = derived(movies, $m => $m.isLoading);
+134
View File
@@ -0,0 +1,134 @@
// Music library landing page data store.
// Powers the focused music landing: hero + horizontal sliders.
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter";
interface MusicState {
// Albums grouped from recently played tracks (backend handles grouping).
recentlyPlayed: MediaItem[];
// Most recently added albums in the music library.
newlyAdded: MediaItem[];
// User playlists.
playlists: MediaItem[];
// Albums the user has played but hasn't returned to in a while.
rediscover: MediaItem[];
// Mix of recently played + rediscover, used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
function createMusicStore() {
const initialState: MusicState = {
recentlyPlayed: [],
newlyAdded: [],
playlists: [],
rediscover: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<MusicState>(initialState);
/**
* Build the hero rotation from a mix of recently played and rediscover
* albums, interleaved so the banner alternates "fresh in your ears" with
* "remember this?". De-duplicates by id and prefers items that have artwork.
*/
function buildHero(recent: MediaItem[], rediscover: MediaItem[]): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
const recentPool = recent.filter(hasArt);
const rediscoverPool = rediscover.filter(hasArt);
const result: MediaItem[] = [];
const seen = new Set<string>();
const maxLen = Math.max(recentPool.length, rediscoverPool.length);
for (let i = 0; i < maxLen && result.length < 6; i++) {
for (const candidate of [recentPool[i], rediscoverPool[i]]) {
if (candidate && !seen.has(candidate.id)) {
seen.add(candidate.id);
result.push(candidate);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover] = await Promise.all([
repo.getRecentlyPlayedAudio(SECTION_LIMIT),
repo.getItems(libraryId, {
includeItemTypes: ["MusicAlbum"],
sortBy: "DateCreated",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
}),
repo.getItems(libraryId, {
includeItemTypes: ["Playlist"],
sortBy: "SortName",
sortOrder: "Ascending",
recursive: true,
limit: SECTION_LIMIT,
}),
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
]);
// HACK: drop the "Podcasts" folder that lives inside the music library.
const recentlyPlayedAlbums = excludePodcasts(recentlyPlayed);
const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
const playlistItems = excludePodcasts(playlistsResult.items);
const rediscoverAlbums = excludePodcasts(rediscover);
const heroItems = buildHero(recentlyPlayedAlbums, rediscoverAlbums);
update(s => ({
...s,
recentlyPlayed: recentlyPlayedAlbums,
newlyAdded: newlyAddedAlbums,
playlists: playlistItems,
rediscover: rediscoverAlbums,
heroItems,
isLoading: false,
}));
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load music sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load music sections:", error);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const music = createMusicStore();
export const musicHeroItems = derived(music, $m => $m.heroItems);
export const isMusicLoading = derived(music, $m => $m.isLoading);
+176
View File
@@ -0,0 +1,176 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface TvState {
// Episodes the user can resume (continue watching).
continueWatching: MediaItem[];
// Next unwatched episode per in-progress series.
nextUp: MediaItem[];
// Recently added items in the TV library (series/seasons/episodes).
recentlyAdded: MediaItem[];
// One slider per genre (top genres by show count).
genreRows: GenreRow[];
// Mix used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
const MAX_GENRES_PROBED = 20;
function createTvStore() {
const initialState: TvState = {
continueWatching: [],
nextUp: [],
recentlyAdded: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<TvState>(initialState);
/**
* Build the hero rotation. Prefer in-progress episodes (most personal),
* then fall back to next-up, then recently added. De-duplicates by id and
* prefers items that carry backdrop/primary artwork for a good banner.
*/
function buildHero(
continueWatching: MediaItem[],
nextUp: MediaItem[],
recentlyAdded: MediaItem[]
): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, nextUp, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [resume, nextUp, latest] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
]);
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie");
const heroItems = buildHero(continueWatching, nextUp, latest);
update(s => ({
...s,
continueWatching,
nextUp,
recentlyAdded: latest,
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load TV sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load TV sections:", error);
}
}
/**
* Build one slider per genre, showing the top-rated series in each. We probe
* a bounded set of genres in parallel, drop empty ones, then keep the genres
* with the most shows (so niche/near-empty genres don't crowd the page).
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
const probed = genres.slice(0, MAX_GENRES_PROBED);
const rows = await Promise.all(
probed.map(async (genre): Promise<GenreRow> => {
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["Series"],
genres: [genre.name],
sortBy: "CommunityRating",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
});
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
})
);
const genreRows = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length)
.slice(0, MAX_GENRE_ROWS);
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load TV genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const tv = createTvStore();
export const tvHeroItems = derived(tv, $t => $t.heroItems);
export const isTvLoading = derived(tv, $t => $t.isLoading);
+30
View File
@@ -0,0 +1,30 @@
// HACK: hide "Podcasts" from the music library.
//
// The user stores podcasts inside the music library under a folder/album named
// "Podcasts", so they leak into album/artist/track/playlist queries. Jellyfin's
// item queries here don't give us a clean server-side exclusion for that folder,
// so we filter client-side by name. This is intentionally a blunt instrument:
// anything whose own name, album, or (album) artist is literally "Podcasts" is
// dropped. If the folder is ever renamed, update PODCAST_FOLDER_NAME.
import type { MediaItem } from "$lib/api/types";
const PODCAST_FOLDER_NAME = "podcasts";
function isPodcastName(value: string | null | undefined): boolean {
return value?.trim().toLowerCase() === PODCAST_FOLDER_NAME;
}
/** True when an item belongs to the "Podcasts" folder/album and should be hidden. */
export function isPodcastItem(item: MediaItem): boolean {
return (
isPodcastName(item.name) ||
isPodcastName(item.albumName) ||
isPodcastName(item.albumArtist) ||
(item.artists?.some(isPodcastName) ?? false)
);
}
/** Remove "Podcasts" entries from a list of music items. */
export function excludePodcasts(items: MediaItem[]): MediaItem[] {
return items.filter((item) => !isPodcastItem(item));
}
+14
View File
@@ -62,6 +62,20 @@
return;
}
// Route to dedicated TV library landing page
if (lib.collectionType === "tvshows") {
library.setCurrentLibrary(lib);
goto("/library/tv");
return;
}
// Route to dedicated movies library landing page
if (lib.collectionType === "movies") {
library.setCurrentLibrary(lib);
goto("/library/movies");
return;
}
// For other library types, load items normally
library.setCurrentLibrary(lib);
library.clearGenres();
+160
View File
@@ -0,0 +1,160 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { currentLibrary } from "$lib/stores/library";
import { movies } from "$lib/stores/movies";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const categories: Category[] = [
{
id: "all",
name: "All Movies",
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
description: "Browse all movies",
route: "/library/movies/all",
},
{
id: "genres",
name: "Genres",
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
description: "Browse by genre",
route: "/library/movies/genres",
},
];
async function load() {
if (!$currentLibrary) {
goto("/library");
return;
}
await movies.loadSections($currentLibrary.id);
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
onMount(async () => {
await load();
markLoaded();
});
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
if (item.type === "Folder") {
goto(`/library/${item.id}`);
} else {
// Movies play directly.
goto(`/player/${item.id}`);
}
}
const heroItems = $derived($movies.heroItems);
const continueWatching = $derived($movies.continueWatching);
const recentlyAdded = $derived($movies.recentlyAdded);
const genreRows = $derived($movies.genreRows);
const isLoading = $derived($movies.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
continueWatching.length > 0 ||
recentlyAdded.length > 0
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
<button
onclick={() => goto("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Continue Watching -->
{#if continueWatching.length > 0}
<Carousel
title="Continue Watching"
items={continueWatching}
onItemClick={handleItemClick}
/>
{/if}
<!-- Recently Added -->
{#if recentlyAdded.length > 0}
<Carousel
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/movies/all")}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/movies/genres`)}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* Movie browser (all movies)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Movie" as const,
title: "Movies",
backPath: "/library/movies",
searchPlaceholder: "Search movies...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
</script>
<GenericMediaListPage {config} />
+127 -102
View File
@@ -1,8 +1,15 @@
<!-- TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039 -->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { auth } from "$lib/stores/auth";
import { currentLibrary } from "$lib/stores/library";
import { music } from "$lib/stores/music";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
@@ -10,10 +17,9 @@
icon: string;
description: string;
route: string;
backgroundImage?: string;
}
let categories: Category[] = [
const categories: Category[] = [
{
id: "tracks",
name: "Tracks",
@@ -42,124 +48,143 @@
description: "Browse by genre",
route: "/library/music/genres",
},
{
id: "playlists",
name: "Playlists",
icon: "M4 6h16M4 10h16M4 14h10M14 14v6l5-3-5-3z",
description: "Your playlists",
route: "/library/music/playlists",
},
];
// Fetch album art for categories
async function loadCategoryImages() {
async function load() {
if (!$currentLibrary) {
console.log("Current library not set yet, retrying...");
goto("/library");
return;
}
try {
const repo = auth.getRepository();
// Fetch a recent album to use as background for albums category
const albums = await repo.getLatestItems($currentLibrary.id, 5);
if (albums.length > 0) {
const albumWithImage = albums.find(a => a.primaryImageTag);
if (albumWithImage) {
categories = categories.map(cat =>
cat.id === "albums"
? { ...cat, backgroundImage: albumWithImage.id }
: cat
);
}
}
// Fetch a recent audio track for tracks category
const tracks = await repo.getRecentlyPlayedAudio(5);
if (tracks.length > 0) {
const trackWithImage = tracks.find((t: typeof tracks[0]) => t.primaryImageTag);
if (trackWithImage) {
categories = categories.map(cat =>
cat.id === "tracks"
? { ...cat, backgroundImage: trackWithImage.id }
: cat
);
}
}
} catch (error) {
console.error("Failed to load category images:", error);
}
await music.loadSections($currentLibrary.id);
}
function getImageUrl(itemId: string | undefined) {
if (!itemId) return undefined;
return `http://tauri.localhost/image/primary/${itemId}?size=400&quality=95`;
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
onMount(() => {
loadCategoryImages();
onMount(async () => {
await load();
markLoaded();
});
function handleCategoryClick(route: string) {
goto(route);
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
// Albums, artists, and playlists all browse to a detail page.
goto(`/library/${item.id}`);
}
const heroItems = $derived($music.heroItems);
const recentlyPlayed = $derived($music.recentlyPlayed);
const newlyAdded = $derived($music.newlyAdded);
const playlists = $derived($music.playlists);
const rediscover = $derived($music.rediscover);
const isLoading = $derived($music.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
recentlyPlayed.length > 0 ||
newlyAdded.length > 0 ||
playlists.length > 0 ||
rediscover.length > 0
);
</script>
<div class="space-y-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Music Library</h1>
<p class="text-gray-400 mt-1">Choose a category to browse</p>
</div>
<button
onclick={() => goto('/library')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- Category Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{#each categories as category (category.id)}
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">Music</h1>
<button
onclick={() => handleCategoryClick(category.route)}
class="group relative bg-[var(--color-surface)] rounded-xl overflow-hidden hover:shadow-lg transition-all duration-200 text-left h-48"
style={category.backgroundImage ? `background-image: url('${getImageUrl(category.backgroundImage)}')` : ''}
onclick={() => goto("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<!-- Background image overlay -->
{#if category.backgroundImage}
<div class="absolute inset-0 bg-black/40 group-hover:bg-black/50 transition-colors"></div>
{:else}
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-transparent"></div>
{/if}
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<!-- Content -->
<div class="relative z-10 h-full flex flex-col justify-between p-6">
<!-- Icon and text section -->
<div>
<!-- Icon -->
<div class="w-14 h-14 mb-4 rounded-full bg-[var(--color-jellyfin)]/30 backdrop-blur-sm flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-7 h-7 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Recently Listened -->
{#if recentlyPlayed.length > 0}
<Carousel
title="Recently Listened"
items={recentlyPlayed}
onItemClick={handleItemClick}
/>
{/if}
<!-- Playlists -->
{#if playlists.length > 0}
<Carousel
title="Playlists"
items={playlists}
onItemClick={handleItemClick}
showAll={() => goto("/library/music/playlists")}
/>
{/if}
<!-- Newly Added -->
{#if newlyAdded.length > 0}
<Carousel
title="Newly Added"
items={newlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/music/albums")}
/>
{/if}
<!-- Rediscover -->
{#if rediscover.length > 0}
<Carousel
title="Haven't listened to in a while"
items={rediscover}
onItemClick={handleItemClick}
/>
{/if}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<!-- Text -->
<h2 class="text-xl font-bold text-white mb-1 group-hover:text-[var(--color-jellyfin)] transition-colors">
{category.name}
</h2>
<p class="text-gray-300 text-sm">
{category.description}
</p>
</div>
<!-- Arrow indicator -->
<div class="flex items-center text-[var(--color-jellyfin)] opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-sm font-medium mr-1">Browse</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
{/each}
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
</div>
{/if}
+176
View File
@@ -0,0 +1,176 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { currentLibrary } from "$lib/stores/library";
import { tv } from "$lib/stores/tv";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const categories: Category[] = [
{
id: "shows",
name: "All Shows",
icon: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z",
description: "Browse all series",
route: "/library/tv/shows",
},
{
id: "genres",
name: "Genres",
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
description: "Browse by genre",
route: "/library/shows/genres",
},
];
async function load() {
if (!$currentLibrary) {
goto("/library");
return;
}
await tv.loadSections($currentLibrary.id);
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
onMount(async () => {
await load();
markLoaded();
});
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
switch (item.type) {
case "Series":
case "Season":
case "Folder":
goto(`/library/${item.id}`);
break;
default:
// Episodes and movies play directly.
goto(`/player/${item.id}`);
break;
}
}
const heroItems = $derived($tv.heroItems);
const continueWatching = $derived($tv.continueWatching);
const nextUp = $derived($tv.nextUp);
const recentlyAdded = $derived($tv.recentlyAdded);
const genreRows = $derived($tv.genreRows);
const isLoading = $derived($tv.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
continueWatching.length > 0 ||
nextUp.length > 0 ||
recentlyAdded.length > 0
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
<button
onclick={() => goto("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Continue Watching -->
{#if continueWatching.length > 0}
<Carousel
title="Continue Watching"
items={continueWatching}
onItemClick={handleItemClick}
/>
{/if}
<!-- Next Up -->
{#if nextUp.length > 0}
<Carousel
title="Next Up"
items={nextUp}
onItemClick={handleItemClick}
/>
{/if}
<!-- Recently Added -->
{#if recentlyAdded.length > 0}
<Carousel
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/tv/shows")}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/shows/genres`)}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* TV show browser (all series)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Series" as const,
title: "TV Shows",
backPath: "/library/tv",
searchPlaceholder: "Search shows...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
</script>
<GenericMediaListPage {config} />