Alphabet rail page-chasing + Songs Like fix + scrubber polish #70

Merged
bvandeusen merged 6 commits from dev into main 2026-06-01 23:36:08 -04:00
Owner

Summary

  • Web — alphabet rail load-on-demand. Clicking a letter whose bucket isn't loaded yet now chases pages via onLoadMore until the letter surfaces, then scrolls. Always-visible #/A-Z/& so the rail works as a navigation tool from any scroll position.
  • Web — Songs Like play overlay. Per-artist songs_like_artist playlists all share one variant tag but exist one-per-seed-artist, so routing the play handler through systemShuffle(variant) hit the wrong playlist or 404'd. Detect via seed_artist_id and fall through to getPlaylist(playlist.id) for those; queue still tagged with the variant so source attribution is preserved.
  • Web + Android — scrubber smoothing. player.position only ticks ~4 Hz so the thumb stepped visibly. New useSmoothPosition() Svelte hook (mirrors Android's rememberSmoothPositionMs) resets on each canonical tick / seek / track change and extrapolates per-frame at playback rate. Wired into both PlayerBar and now-playing.
  • Android — NowPlaying scrubber thumb restyle. Replaces the M3 SliderDefaults thumb with a plain 14dp filled circle (no state-layer halo, no border) to match the web's <input type="range" accent-color> look.

Test plan

  • Library/Artists rail: click a letter far past the loaded window — pages chase, view scrolls when it surfaces, spinner during chase
  • Click a Songs Like card's hover play button — correct artist's playlist starts; play_started carries source=songs_like_artist
  • Web mini + full player thumbs glide smoothly between server ticks; seek-drag still authoritative
  • Android NowPlaying scrubber thumb reads as a small flat filled circle (operator device check)
## Summary - **Web — alphabet rail load-on-demand.** Clicking a letter whose bucket isn't loaded yet now chases pages via `onLoadMore` until the letter surfaces, then scrolls. Always-visible #/A-Z/& so the rail works as a navigation tool from any scroll position. - **Web — Songs Like play overlay.** Per-artist `songs_like_artist` playlists all share one variant tag but exist one-per-seed-artist, so routing the play handler through `systemShuffle(variant)` hit the wrong playlist or 404'd. Detect via `seed_artist_id` and fall through to `getPlaylist(playlist.id)` for those; queue still tagged with the variant so source attribution is preserved. - **Web + Android — scrubber smoothing.** `player.position` only ticks ~4 Hz so the thumb stepped visibly. New `useSmoothPosition()` Svelte hook (mirrors Android's `rememberSmoothPositionMs`) resets on each canonical tick / seek / track change and extrapolates per-frame at playback rate. Wired into both PlayerBar and now-playing. - **Android — NowPlaying scrubber thumb restyle.** Replaces the M3 SliderDefaults thumb with a plain 14dp filled circle (no state-layer halo, no border) to match the web's `<input type="range" accent-color>` look. ## Test plan - [ ] Library/Artists rail: click a letter far past the loaded window — pages chase, view scrolls when it surfaces, spinner during chase - [ ] Click a Songs Like card's hover play button — correct artist's playlist starts; play_started carries `source=songs_like_artist` - [ ] Web mini + full player thumbs glide smoothly between server ticks; seek-drag still authoritative - [ ] Android NowPlaying scrubber thumb reads as a small flat filled circle (operator device check)
bvandeusen added 3 commits 2026-06-01 23:03:19 -04:00
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).
fix(web): songs-like play overlay + scrubber smoothing
test-web / test (push) Failing after 45s
74bae74f9f
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.
feat(android): restyle NowPlaying scrubber thumb to match web client
android / Build + lint + test (push) Successful in 3m39s
b77a7121ca
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
<input type=\"range\" accent-color> rendering. Slider's 48dp hit
slop is intrinsic to the composable, so tapability is unchanged.
bvandeusen added 1 commit 2026-06-01 23:24:08 -04:00
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.
bvandeusen added 1 commit 2026-06-01 23:29:38 -04:00
fix(android): latch NowPlaying drag-dismiss to prevent back-stack underflow
android / Build + lint + test (push) Failing after 1m24s
6a7d9afdbc
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.
bvandeusen added 1 commit 2026-06-01 23:31:47 -04:00
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.
bvandeusen merged commit 7ede83a586 into main 2026-06-01 23:36:08 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#70