From d4c6bb3f2d049cf679c36feeefb08c29fd465294 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 22:29:13 -0400 Subject: [PATCH 1/6] feat(web): alphabet rail chases pages on click for unloaded letters Operator: prior rail disabled empty buckets, so clicking a letter like 'Z' did nothing if it wasn't loaded yet. AlphabeticalGrid now accepts a paginated source and walks pages on click until the target letter surfaces. - New props: hasMore, onLoadMore. When hasMore=true, every empty bucket stays enabled (clicking will chase pages); when hasMore= false, only populated buckets are clickable. - jumpTo(bucket): if populated, scrollIntoView. Otherwise loop onLoadMore + tick() until the bucket appears or no more pages. Loader2 spinner replaces the letter on the pending button; cursor:wait + aria-busy. Other rail buttons disable while one is pending so clicks don't stack. - Library Artists + Albums pass hasMore + onLoadMore through to the grid. Existing InfiniteScrollSentinel still handles scroll-driven loading. Test: new case asserts rail enables empty buckets when hasMore=true (the click would trigger onLoadMore). --- .../lib/components/AlphabeticalGrid.svelte | 74 +++++++++++++++++-- .../lib/components/AlphabeticalGrid.test.ts | 24 +++++- web/src/routes/library/albums/+page.svelte | 2 + web/src/routes/library/artists/+page.svelte | 2 + 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte index 667cf54d..39781dc5 100644 --- a/web/src/lib/components/AlphabeticalGrid.svelte +++ b/web/src/lib/components/AlphabeticalGrid.svelte @@ -1,14 +1,25 @@
@@ -80,17 +123,28 @@ vertically centered via position:sticky + top:50%. --> {/if} @@ -146,4 +200,14 @@ color: color-mix(in srgb, var(--fs-ash) 50%, transparent); cursor: default; } + .rail-btn.pending { + color: var(--fs-accent); + cursor: wait; + } + :global(.rail-btn .spin) { + animation: spin 1s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } diff --git a/web/src/lib/components/AlphabeticalGrid.test.ts b/web/src/lib/components/AlphabeticalGrid.test.ts index 6edb768e..b7b10a80 100644 --- a/web/src/lib/components/AlphabeticalGrid.test.ts +++ b/web/src/lib/components/AlphabeticalGrid.test.ts @@ -23,27 +23,43 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({ type AnyProps = any; describe('AlphabeticalGrid', () => { - test('rail renders the full #/A-Z/& set; empty buckets are disabled', () => { + test('rail renders the full #/A-Z/& set; empty buckets are disabled when no more data could load', () => { render(AlphabeticalGrid, { props: { items, getKey: (it: Item) => it.key, item: itemSnippet + // hasMore default = false → empty buckets stay disabled. } as AnyProps }); // Populated buckets are enabled with 'Jump to ' label. expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled(); expect(screen.getByRole('button', { name: 'Jump to B' })).not.toBeDisabled(); expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled(); - // Empty buckets still render but are disabled and labelled - // 'X — no entries' so screen readers announce the empty state. + // Empty buckets disabled when nothing more could load. aria-label + // changes too so screen readers announce the empty state. expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Z — no entries' })).toBeDisabled(); - // # (numbers) and & (symbols) always present too. expect(screen.getByRole('button', { name: 'Numbers — no entries' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Symbols — no entries' })).toBeDisabled(); }); + test('with hasMore=true, empty buckets are enabled (clicking would trigger onLoadMore)', () => { + render(AlphabeticalGrid, { + props: { + items, + getKey: (it: Item) => it.key, + item: itemSnippet, + hasMore: true, + onLoadMore: () => Promise.resolve() + } as AnyProps + }); + // Even with no C/Z items loaded, the buttons remain enabled so a + // click can chase pages until the letter surfaces. + expect(screen.getByRole('button', { name: 'Jump to C' })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: 'Jump to Z' })).not.toBeDisabled(); + }); + test('renders items in given order followed by the jump rail', () => { const { container } = render(AlphabeticalGrid, { props: { diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte index 53bdd960..9c0eb881 100644 --- a/web/src/routes/library/albums/+page.svelte +++ b/web/src/routes/library/albums/+page.svelte @@ -73,6 +73,8 @@ a.sort_title || a.title} + hasMore={!!query.hasNextPage} + onLoadMore={() => query.fetchNextPage()} > {#snippet item(album: AlbumRef)} diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte index fe36e085..edaf6287 100644 --- a/web/src/routes/library/artists/+page.svelte +++ b/web/src/routes/library/artists/+page.svelte @@ -71,6 +71,8 @@ a.sort_name || a.name} + hasMore={!!query.hasNextPage} + onLoadMore={() => query.fetchNextPage()} > {#snippet item(a: ArtistRef)} -- 2.52.0 From 74bae74f9fee6f8ae0af7252fa7057a89fc200ac Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 22:39:24 -0400 Subject: [PATCH 2/6] fix(web): songs-like play overlay + scrubber smoothing PlaylistCard: per-artist system variants (songs_like_artist) all share one variant tag but exist as one playlist per seed artist; routing their play handler through systemShuffle(variant) hit the wrong playlist (or 404). Detect via seed_artist_id != null and fall through to getPlaylist(playlist.id) for those; still tag the queue with the variant so source attribution stays correct. smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors Android's rememberSmoothPositionMs. player.position only updates ~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly; $effect resets on each canonical tick / seek / track-change and a rAF loop extrapolates at playback rate between ticks. Wired into both PlayerBar.svelte (mini + expanded seek rows) and now-playing/+page.svelte. Seek input handler still reads the raw range value (not smoothed.value) so user drags stay authoritative. --- web/src/lib/components/PlayerBar.svelte | 21 ++++++--- web/src/lib/components/PlaylistCard.svelte | 15 +++++- web/src/lib/player/smoothPosition.svelte.ts | 51 +++++++++++++++++++++ web/src/routes/now-playing/+page.svelte | 9 +++- 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/player/smoothPosition.svelte.ts diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index fc9bdc0a..30dc8ac4 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -14,11 +14,17 @@ import { formatDuration } from '$lib/media/duration'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor'; + import { useSmoothPosition } from '$lib/player/smoothPosition.svelte'; import LikeButton from './LikeButton.svelte'; import TrackMenu from './TrackMenu.svelte'; const current = $derived(player.current); + // Per-frame interpolated playhead so the scrubber bar moves + // smoothly between server ticks instead of stepping every ~250ms. + // Reset on track/play-state change via the helper's $effect. + const smoothed = useSmoothPosition(); + const skipPrevDisabled = $derived(player.index === 0 && player.position < 3); const skipNextDisabled = $derived( player.index === player.queue.length - 1 && player.repeat === 'off' @@ -226,10 +232,12 @@
- +
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} @@ -346,10 +354,11 @@
- +
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 40948f51..3f871edf 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -64,7 +64,15 @@ starting = true; try { const variant = playlist.system_variant; - if (variant != null) { + // systemShuffle keys by variant alone, which only works for + // SINGLE-instance variants (for_you, discover, deep_cuts, …). + // songs_like_artist has one playlist PER seed artist; using + // the variant alone would return the wrong one (or fail). + // For those, fall through to getPlaylist so the play handler + // hits the exact playlist the user clicked on. Detect via + // seed_artist_id which is non-null only for per-artist mixes. + const isPerArtist = playlist.seed_artist_id != null; + if (variant != null && !isPerArtist) { // #415: server returns the rotation-aware order (unplayed // this rotation first). Play it as-is — no client shuffle — // and tag the queue so play_started carries `source` and @@ -78,7 +86,10 @@ const detail = await getPlaylist(playlist.id); const refs = toTrackRefs(detail.tracks); if (refs.length > 0) { - playQueue(refs, 0); + // Tag the queue with the variant when one exists so play + // events still carry the correct source attribution even + // though we went through the per-id path. + playQueue(refs, 0, variant != null ? { source: variant } : undefined); } } } finally { diff --git a/web/src/lib/player/smoothPosition.svelte.ts b/web/src/lib/player/smoothPosition.svelte.ts new file mode 100644 index 00000000..7ba471cf --- /dev/null +++ b/web/src/lib/player/smoothPosition.svelte.ts @@ -0,0 +1,51 @@ +import { player } from './store.svelte'; + +/** + * Reactive smoothed playhead position in seconds. Mirrors Android's + * `rememberSmoothPositionMs`: the underlying `player.position` updates + * every ~250 ms (HTML audio `timeupdate` fires ~4 Hz), so reading it + * directly snaps the scrubber forward in discrete steps. This helper + * extrapolates per-frame between server ticks so the bar glides at + * playback rate. + * + * The `$effect` re-runs whenever the canonical position / isPlaying / + * duration changes, which both handles natural ticks (re-sync to the + * new authoritative value) and seeks (instant jump on user action). + * The rAF loop advances at wall-clock seconds since the segment + * started, so as long as the canonical position keeps up with real + * time the resync on each tick is sub-frame and invisible. + * + * Returns a `{ value }` accessor instead of a bare number so callers + * can read it reactively inside components (Svelte's rune wiring + * follows the property access). + */ +export function useSmoothPosition(): { readonly value: number } { + let displayed = $state(player.position); + + $effect(() => { + // Reset whenever the canonical position changes (server tick, + // seek, track change) so we anchor to the truth. + displayed = player.position; + + if (!player.isPlaying || player.duration <= 0) return; + + const startReal = performance.now(); + const startPos = player.position; + const duration = player.duration; + + let rafId = 0; + const tick = () => { + const elapsed = (performance.now() - startReal) / 1000; + displayed = Math.min(startPos + elapsed, duration); + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }); + + return { + get value() { + return displayed; + } + }; +} diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte index 54ccd4a1..ad963b81 100644 --- a/web/src/routes/now-playing/+page.svelte +++ b/web/src/routes/now-playing/+page.svelte @@ -14,11 +14,16 @@ } from '$lib/player/store.svelte'; import { formatDuration } from '$lib/media/duration'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; + import { useSmoothPosition } from '$lib/player/smoothPosition.svelte'; import LikeButton from '$lib/components/LikeButton.svelte'; import { pageTitle } from '$lib/branding'; const current = $derived(player.current); + // Per-frame interpolated playhead so the scrubber thumb glides + // smoothly between server ticks. Shares the helper with PlayerBar. + const smoothed = useSmoothPosition(); + const skipPrevDisabled = $derived(player.index === 0 && player.position < 3); const skipNextDisabled = $derived( player.index === player.queue.length - 1 && player.repeat === 'off' @@ -123,13 +128,13 @@ min="0" max={player.duration || 0} step="0.1" - value={player.position} + value={smoothed.value} oninput={onSeekInput} aria-label="Seek" class="w-full accent-accent" />
- {formatDuration(player.position)} + {formatDuration(smoothed.value)} {formatDuration(player.duration)}
-- 2.52.0 From b77a7121cabe2fe9819acbfa4ab4287a399f760b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 22:40:35 -0400 Subject: [PATCH 3/6] feat(android): restyle NowPlaying scrubber thumb to match web client User feedback: dislikes both the old M3 20dp thumb and the current 10dp slim variant; wants the bare HTML range thumb the web client shows. Replace SliderDefaults.Thumb with a plain Box(CircleShape + accent fill, 14dp). Drops the M3 state-layer halo on press and the implicit elevation/border so the on-screen result matches a rendering. Slider's 48dp hit slop is intrinsic to the composable, so tapability is unchanged. --- .../minstrel/player/ui/NowPlayingScreen.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 96f24442..46a74516 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -31,7 +32,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Slider -import androidx.compose.ui.unit.DpSize import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.ui.graphics.Brush @@ -458,16 +458,17 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un modifier = Modifier.fillMaxWidth(), colors = sliderColors, interactionSource = interactionSource, - // Slim 10dp thumb shrinks the scrubber's visual weight. M3 - // default is 20dp; halving keeps it tappable (Slider's own - // 48dp hit slop is unchanged) but lets it recede into the UI. - // Track left at default — the colour pin alone already - // matches Flutter's slate look. + // Plain filled circle to match the web client's `` thumb — flat, no state-layer + // ring, no border. M3's SliderDefaults.Thumb paints a state + // layer halo on press; we drop it for visual parity. Slider's + // 48dp hit slop still applies, so tapability is unchanged. thumb = { - SliderDefaults.Thumb( - interactionSource = interactionSource, - colors = sliderColors, - thumbSize = DpSize(10.dp, 10.dp), + Box( + modifier = Modifier + .size(14.dp) + .clip(CircleShape) + .background(accent), ) }, ) -- 2.52.0 From 15a545a25cfe68b4642dd00cdaac4fd306344a32 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 23:24:06 -0400 Subject: [PATCH 4/6] feat(web): two-pane now-playing on desktop with permanent queue panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full player previously sat dead-center on wide viewports — cover + controls capped at max-w-md left the right two-thirds of the screen empty while the queue remained behind a drawer toggle. Split the layout at lg+: - Extract QueueList from QueueDrawer (header + count + scrollable list body + empty state); the drawer now wraps QueueList for its slide-in chrome, and the now-playing route embeds it directly. - now-playing: at lg+ render the player as a left section + a 384px (xl: 448px) aside with QueueList. Below lg the layout is unchanged single-column and the drawer toggle in the bottom row still opens it (lg:hidden on the toggle button keeps it out of the way once the panel is permanent). - QueueList owns the close X conditionally — drawer passes onClose and the bind:closeButtonRef for focus management; embedded panel omits both since it has no dismiss action. QueueTrackRow's drag-to-reorder, click-to-jump, and current-row highlight all carry over for free since they're owned by the row component. --- web/src/lib/components/QueueDrawer.svelte | 41 ++-------------- web/src/lib/components/QueueList.svelte | 57 +++++++++++++++++++++++ web/src/routes/now-playing/+page.svelte | 19 +++++++- 3 files changed, 77 insertions(+), 40 deletions(-) create mode 100644 web/src/lib/components/QueueList.svelte diff --git a/web/src/lib/components/QueueDrawer.svelte b/web/src/lib/components/QueueDrawer.svelte index 924b3f26..075c52f4 100644 --- a/web/src/lib/components/QueueDrawer.svelte +++ b/web/src/lib/components/QueueDrawer.svelte @@ -1,18 +1,10 @@ + +
+
+
+

Queue

+

+ {player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'} + {#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if} +

+
+ {#if onClose} + + {/if} +
+ +
+ {#if player.queue.length === 0} +

No tracks queued.

+ {:else} + {#each player.queue as track, i (track.id)} + + {/each} + {/if} +
+
diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte index ad963b81..03aa9841 100644 --- a/web/src/routes/now-playing/+page.svelte +++ b/web/src/routes/now-playing/+page.svelte @@ -16,6 +16,7 @@ import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import { useSmoothPosition } from '$lib/player/smoothPosition.svelte'; import LikeButton from '$lib/components/LikeButton.svelte'; + import QueueList from '$lib/components/QueueList.svelte'; import { pageTitle } from '$lib/branding'; const current = $derived(player.current); @@ -89,7 +90,13 @@

{:else} -
+ +
+
{player.queue.length}
+
+
{/if} -- 2.52.0 From 6a7d9afdbc81c44d80d88f1d0d9c8cefa560b848 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 23:29:36 -0400 Subject: [PATCH 5/6] fix(android): latch NowPlaying drag-dismiss to prevent back-stack underflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator reproduced black-screen-on-resume by drag-down dismissing the full player. Root cause: the NestedScrollConnection accumulator crossed the dismiss threshold, called navController.popBackStack(), reset accumulated to 0 — but the user's finger was still down and the pop transition was still running. The next frame's onPostScroll re-accumulated and re-fired onDismiss(), popping the screen BENEATH NowPlaying. When that left the back stack empty the NavHost had no destination to draw, producing a black window until the process was killed and the activity was cold-launched. Add a `dismissed` latch that survives until the connection is disposed (which only happens when NowPlayingScreen leaves the composition, i.e. the pop completes). After the latch sets we consume the remaining drag (return `available`) so the underlying scrollable doesn't paint over-scroll while the pop transitions. onPreFling also bails after dismissal so the fling can't restart the accumulator. --- .../minstrel/player/ui/NowPlayingScreen.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 46a74516..3ceea516 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -189,16 +189,29 @@ private fun rememberDragDismissConnection( object : NestedScrollConnection { private var accumulated = 0f + // One-shot latch. popBackStack is async — between the first + // dismissal and the screen actually leaving the composition, + // the user's finger is still down and more onPostScroll + // frames arrive. Without this guard the accumulator rebuilds + // and onDismiss() fires a second time, popping the screen + // BENEATH NowPlaying. If that leaves the back stack empty + // the NavHost renders nothing → black screen on resume. + private var dismissed = false + override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { + // After dismissal, eat all remaining drag so the + // scrollable doesn't paint over-scroll deltas during the + // pop transition. + if (dismissed) return available if (source != NestedScrollSource.UserInput) return Offset.Zero return if (available.y > 0f) { accumulated += available.y if (accumulated >= thresholdPx) { - accumulated = 0f + dismissed = true onDismiss() } Offset(0f, available.y) @@ -209,6 +222,7 @@ private fun rememberDragDismissConnection( } override suspend fun onPreFling(available: Velocity): Velocity { + if (dismissed) return available accumulated = 0f return Velocity.Zero } -- 2.52.0 From 6ac36dd334680ac280da65f2786b5eb73308c5a7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 23:31:45 -0400 Subject: [PATCH 6/6] fix(web): user-playlist play omits third arg to satisfy contract test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 74bae74f routed per-artist system mixes through getPlaylist and always passed the source attribution as the third arg, falling back to undefined for true user playlists. Vitest's toHaveBeenCalledWith is arity-strict — playQueue(refs, 0, undefined) is not the same as playQueue(refs, 0) — so the PlaylistCard contract test failed on the user-playlist case. Split the call: pass three args only when a variant tag exists, two args for user playlists. Preserves source attribution for songs_like_artist and keeps user playlists source-less as the test pins. --- web/src/lib/components/PlaylistCard.svelte | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 3f871edf..84e358c0 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -86,10 +86,17 @@ const detail = await getPlaylist(playlist.id); const refs = toTrackRefs(detail.tracks); if (refs.length > 0) { - // Tag the queue with the variant when one exists so play - // events still carry the correct source attribution even - // though we went through the per-id path. - playQueue(refs, 0, variant != null ? { source: variant } : undefined); + // Per-artist system mixes (songs_like_artist) carry a + // variant tag so play_started still attributes the source + // correctly even though we routed through the per-id path. + // True user playlists (variant == null) call with two args + // so source attribution stays absent — the PlaylistCard + // test pins this contract. + if (variant != null) { + playQueue(refs, 0, { source: variant }); + } else { + playQueue(refs, 0); + } } } } finally { -- 2.52.0