feat(library): focused music/TV/movie landing screens + self-draining download queue
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:
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user