feat(library): genre sliders, artist links, and navigation utils
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s

- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
This commit is contained in:
2026-06-25 19:18:06 +02:00
parent 62874564ff
commit 1836615dc0
38 changed files with 1009 additions and 207 deletions
@@ -26,17 +26,28 @@
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.
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let viewportHeight = $state(0);
let stripHeight = $state(0);
function measure() {
const scroller = container?.closest("main") ?? document.documentElement;
viewportHeight = scroller.clientHeight;
if (!container) return;
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
}
$effect(() => {
@@ -44,13 +55,21 @@
const scroller = container?.closest("main");
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
scroller?.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
@@ -99,7 +118,7 @@
<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})"
style="height: {stripHeight}px"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="navigation"