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)
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).
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.
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.
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
onLoadMoreuntil the letter surfaces, then scrolls. Always-visible #/A-Z/& so the rail works as a navigation tool from any scroll position.songs_like_artistplaylists all share one variant tag but exist one-per-seed-artist, so routing the play handler throughsystemShuffle(variant)hit the wrong playlist or 404'd. Detect viaseed_artist_idand fall through togetPlaylist(playlist.id)for those; queue still tagged with the variant so source attribution is preserved.player.positiononly ticks ~4 Hz so the thumb stepped visibly. NewuseSmoothPosition()Svelte hook (mirrors Android'srememberSmoothPositionMs) resets on each canonical tick / seek / track change and extrapolates per-frame at playback rate. Wired into both PlayerBar and now-playing.<input type="range" accent-color>look.Test plan
source=songs_like_artist