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:
@@ -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>
|
||||
Reference in New Issue
Block a user