13 commits since the last main merge. All CI green on the tip (682d7a5e, run 148).
Android (7 commits)
User-reported issues from device testing, all fixed:
AudioPrefetcher — Mirrors Flutter's prefetcher behaviour. Watches PlayerController.uiState + AuthStore.cacheSettings and pre-downloads the next-N tracks (default 5) into the shared SimpleCache via Media3's CacheWriter. Forward skips on a slow network now play from disk instead of stalling on a fresh HTTP connection. CacheSettings.prefetchWindow was shipped at default=5 since M8 but the prefetcher itself was deferred — this closes that gap.
NowPlaying drag-down dismiss — Was wired but never fired because the body Column.verticalScroll won every drag delta before the outer detectVerticalDragGestures saw it. Replaced with a NestedScrollConnection.onPostScroll that accumulates downward over-scroll at the top of the column toward a 200 px threshold. Same primitive Material's ModalBottomSheet uses.
Home first-sign-in flicker — Top-level Crossfade was keyed on the state INSTANCE, and because each section's flow emission creates a new UiState.Success(data), every per-section hydration tick re-ran the 300 ms fade. Keyed on state::class instead; Loading/Success/Empty/Error class transitions still animate, Success→Success recompositions update the LazyColumn normally.
MiniPlayer fill bar — Drop the Slider, render a 2 dp Box-based fill. No scrubber thumb on the mini bar. Tapping still expands to NowPlaying where scrubbing lives.
NowPlaying scrubber slim — Same M3 Slider, but 10 dp thumb (down from 20 dp) via SliderDefaults.Thumb.thumbSize. Spacers above/below shrunk to 4 dp.
Smooth playhead — New rememberSmoothPositionMs helper. produceState keyed on (positionMs, isPlaying, durationMs) resets to the canonical value on each polling tick and runs a withFrameMillis loop advancing at 1 ms/ms in between. Both MiniPlayer and NowPlaying consume the smoothed value — no more 500 ms steps.
Recently Added → LazyHorizontalGrid — Multi-row chunks now share one scroll state, same pattern as Most Played. 2 fixed rows; column-major re-flatten matches web's reading order.
Web UI flavor pass (6 commits, 10 items)
Per operator screenshot review — wall of identical tiles → designed page with rhythm:
Hover-reveal of like/queue/menu cluster (CardActionCluster).
Dropped year line on album tiles.
Single-cover playlist tile with Fraunces title burned in (gradient overlay, no more 2×2 mini-mosaic competing for attention).
Fraunces section headers with an accent rule (after:bg-accent/60).
Subtle 1200×600 radial gradient at the top of the scroll area (color-mix(fs-iron 60%, transparent)).
Hero row above Playlists: 2-up "Today's pick" (For-You) + "Just added" (most recent album), each with a Play button, on a from-accent/15 → surface gradient. Anchors the page.
Hover lift: shadow-sm → shadow-lg + ring-accent/40 on :group-hover for all album tiles.
Dominant-color accent strip above PlayerBar. Canvas-based 1×1 downsample of the cover image; 300 ms transition on track change. Same-origin so no CORS dance.
Tile width bump so ~5–6 fit per row instead of cramming 8–9.
APK channel
android.yml was simplified to testing-only earlier this session; the signed-release build moved into release.yml's android-release job, chained to image-release via needs:. Main pushes still skip android-release by design, so this merge to main builds a fresh :latest image but the bundled APK stays at whatever the last tag-build attached. Force-moving v2026.06.01 after device-testing this PR will rebuild the APK and bundle it.
13 commits since the last main merge. All CI green on the tip (`682d7a5e`, run 148).
## Android (7 commits)
User-reported issues from device testing, all fixed:
- **AudioPrefetcher** — Mirrors Flutter's prefetcher behaviour. Watches `PlayerController.uiState` + `AuthStore.cacheSettings` and pre-downloads the next-N tracks (default 5) into the shared `SimpleCache` via Media3's `CacheWriter`. Forward skips on a slow network now play from disk instead of stalling on a fresh HTTP connection. `CacheSettings.prefetchWindow` was shipped at default=5 since M8 but the prefetcher itself was deferred — this closes that gap.
- **NowPlaying drag-down dismiss** — Was wired but never fired because the body `Column.verticalScroll` won every drag delta before the outer `detectVerticalDragGestures` saw it. Replaced with a `NestedScrollConnection.onPostScroll` that accumulates downward over-scroll at the top of the column toward a 200 px threshold. Same primitive Material's `ModalBottomSheet` uses.
- **Home first-sign-in flicker** — Top-level `Crossfade` was keyed on the state INSTANCE, and because each section's flow emission creates a new `UiState.Success(data)`, every per-section hydration tick re-ran the 300 ms fade. Keyed on `state::class` instead; Loading/Success/Empty/Error class transitions still animate, Success→Success recompositions update the LazyColumn normally.
- **MiniPlayer fill bar** — Drop the Slider, render a 2 dp `Box`-based fill. No scrubber thumb on the mini bar. Tapping still expands to NowPlaying where scrubbing lives.
- **NowPlaying scrubber slim** — Same M3 Slider, but 10 dp thumb (down from 20 dp) via `SliderDefaults.Thumb.thumbSize`. Spacers above/below shrunk to 4 dp.
- **Smooth playhead** — New `rememberSmoothPositionMs` helper. `produceState` keyed on `(positionMs, isPlaying, durationMs)` resets to the canonical value on each polling tick and runs a `withFrameMillis` loop advancing at 1 ms/ms in between. Both MiniPlayer and NowPlaying consume the smoothed value — no more 500 ms steps.
- **Recently Added → LazyHorizontalGrid** — Multi-row chunks now share one scroll state, same pattern as Most Played. 2 fixed rows; column-major re-flatten matches web's reading order.
## Web UI flavor pass (6 commits, 10 items)
Per operator screenshot review — wall of identical tiles → designed page with rhythm:
1. Hover-reveal of like/queue/menu cluster (`CardActionCluster`).
2. Dropped year line on album tiles.
3. Single-cover playlist tile with Fraunces title burned in (gradient overlay, no more 2×2 mini-mosaic competing for attention).
4. Fraunces section headers with an accent rule (`after:bg-accent/60`).
5. Subtle 1200×600 radial gradient at the top of the scroll area (`color-mix(fs-iron 60%, transparent)`).
6. Hero row above Playlists: 2-up "Today's pick" (For-You) + "Just added" (most recent album), each with a Play button, on a `from-accent/15 → surface` gradient. Anchors the page.
7. Tile-size hierarchy: Playlists `w-56` → Recently added `w-48` → Rediscover `w-40`. Freshest content gets the biggest tiles.
8. Hover lift: `shadow-sm` → `shadow-lg` + `ring-accent/40` on `:group-hover` for all album tiles.
9. Dominant-color accent strip above PlayerBar. Canvas-based 1×1 downsample of the cover image; 300 ms transition on track change. Same-origin so no CORS dance.
10. Tile width bump so ~5–6 fit per row instead of cramming 8–9.
## APK channel
`android.yml` was simplified to testing-only earlier this session; the signed-release build moved into `release.yml`'s `android-release` job, chained to `image-release` via `needs:`. Main pushes still skip `android-release` by design, so this merge to main builds a fresh `:latest` image but the bundled APK stays at whatever the last tag-build attached. Force-moving `v2026.06.01` after device-testing this PR will rebuild the APK and bundle it.
CacheSettings.prefetchWindow has shipped at default=5 since M8 but
the prefetcher itself was deferred to a follow-up. Without it,
forward skips re-fetch from the network every time and gapless
transitions stall.
New AudioPrefetcher singleton subscribes to PlayerController.uiState
+ AuthStore.cacheSettings, walks queue[currentIndex+1 .. +window],
and runs each missing track through Media3's CacheWriter against
the same SimpleCache the player reads from (PlayerFactory.simpleCache).
DataSpec uses setKey(trackId) to match PlayerController.toMediaItem's
setCustomCacheKey(id) — without this the player would miss the
cached bytes on read-through.
Reconcile is idempotent: CacheWriter is a no-op when bytes are
already resident, so distinctUntilChanged on (queue, index, window)
gates re-runs and the per-item check is cheap. Window slides cancel
in-flight jobs for tracks that have dropped out (skip-prev, queue
rebuild) so a stale prefetch doesn't keep the network busy.
Eager-constructed via the construct-the-singleton trick in
MinstrelApplication, alongside CacheIndexer and CoverPrefetcher.
Mirrors flutter_client/lib/cache/prefetcher.dart's user-visible
behavior (queue-walk + per-track pin + idempotent reconcile)
implemented with the native Media3 primitives (CacheWriter +
SimpleCache) instead of Flutter's AudioCacheManager.pin.
The previous detectVerticalDragGestures modifier on the Scaffold
never fired because the body Column applies verticalScroll, which
wins the touch-slop competition for every vertical drag delta
before the outer detector sees it. User-visible symptom: swiping
down on the NowPlaying screen did nothing.
Replace with a NestedScrollConnection. verticalScroll offers its
over-scroll deltas to the nearest ancestor connection via the
standard nested-scroll protocol; when the column is at the top of
its scroll range, downward drag deltas surface in onPostScroll
unconsumed (available.y > 0). Accumulate those toward a 200 px
threshold and pop the back stack.
- Upward over-scroll or any consumed delta resets the accumulator
so a partial drag-down followed by drag-up doesn't latch.
- onPreFling resets on lift-off so a lazy swipe doesn't dismiss
later after the user has released.
- Slider thumb retains its own pointerInput and is not affected.
- Source filter (UserInput) ignores nested-scroll-driven
animations (e.g. flings from inner scrollables).
User-visible: Home flickered continuously after first sign-in until
all sections settled. The top-level Crossfade keyed on the state
INSTANCE — and because each section's flow emission produces a new
UiState.Success(data), Crossfade ran its 300ms fade animation on
every per-section hydration tick. Six sections cascading in over
~1s read as continuous flicker.
Fix: key the Crossfade on state::class. Loading -> Success -> Empty
-> Error class transitions still animate; Success -> Success(with
more sections) recompositions just update the LazyColumn normally
through Compose's standard diff path.
Three closely-related player polish changes:
1. MiniPlayer (#74) — the bottom bar's progress is a 4dp Box-based
fill, not a Slider. No thumb, no drag handle. Tapping the bar
still expands to NowPlaying where scrubbing lives.
2. NowPlaying scrubber (#75) — keep the M3 Slider (still
interactive for seek) but shrink the thumb from the 20dp default
to 10dp via SliderDefaults.Thumb's thumbSize slot. Slider's own
48dp hit slop is unchanged so tappability stays. Spacers above
and below ScrubberRow drop from 8dp to 4dp.
3. Smooth playhead (#76) — new SmoothPosition.kt with
rememberSmoothPositionMs(). PlayerController.uiState polls the
underlying ExoPlayer position roughly every 500ms; reading it
directly steps the scrubber by half-seconds, which reads as
jumpy. The helper resets to the canonical positionMs on each
tick (and on seek/track-change/duration-change) and runs a
withFrameMillis loop while isPlaying to advance the displayed
value at 1ms/ms between ticks. Pause/end/no-duration short-
circuit the loop. Both MiniPlayer's fill bar and the NowPlaying
scrubber consume the smoothed value.
Previously Recently Added chunked into rows of 25 and rendered each
chunk as its own LazyRow, so chunks scrolled independently. Same
pattern Most Played uses (one LazyHorizontalGrid where all rows
scroll as a single panel) now applies to Recently Added.
- New RECENTLY_ADDED_GRID_ROWS = 2 + RECENTLY_ADDED_GRID_HEIGHT_DP =
440 (matches a 200dp tile × 2 rows + the 8dp inter-row gap).
- New RecentlyAddedGrid composable owns the section header + the
LazyHorizontalGrid. Column-major re-flattening matches Web's
row-major reading order on screen (top row first, then bottom).
- recentlyAddedSection collapses from itemsIndexed-over-chunks to
a single item { RecentlyAddedGrid(...) } in the outer LazyColumn.
- AlbumsRow stays untouched (still used by Rediscover, etc.).
The other multi-section helpers (Rediscover, Last Played, Playlists)
are single-row by design and don't need this treatment.
First wave of Home visual polish (UI tasks 78/79/81/85):
- CardActionCluster like/queue/menu cluster is now opacity-0 and fades
in on group-hover or focus-within. The group class moves from the
inner anchor to the outer card wrapper so the cluster (positioned
outside the anchor) participates in the same hover scope. PlaylistCard
refresh kebab gets the same treatment.
- AlbumCard drops the year line. Title plus artist is enough on Home
tiles; the album detail page still shows the year.
- HorizontalScrollRow h2 picks up a flex-baseline layout with a trailing
12-wide accent rule (after:bg-accent/60), so section headers read as
chapter breaks instead of identical plain text.
- AlbumCard art-wrap is shadow-sm by default and lifts to shadow-lg
plus ring-accent/40 on hover. Pairs with the existing
group-hover:scale-1.03 for a clean lift-on-mouseover feel.
Second wave of Home visual polish (UI tasks 80/82/84):
- Shell main background gets a 1200x600 radial gradient at the top
using color-mix on fs-iron so the page reads with subtle depth
instead of as a flat slab.
- PlaylistCard moves the title onto the cover with a bottom-to-top
gradient overlay. The server-generated 2x2 collage becomes mood
texture; the Fraunces playlist name becomes the dominant element.
Track count and refresh label stay below the art. Mirror albums
shadow-sm/shadow-lg/ring-accent hover treatment on the art-wrap.
- Tile-size hierarchy: Playlists w-52, Recently added w-44,
Rediscover w-40. Visual weight tapers as you scroll down the
page so the freshest content reads as most important.
#9 — new lib/media/dominantColor.ts: load cover image, downsample to
1x1 canvas, read pixel. Approximates the dominant tone via the
browser's bilinear mean — close enough for an ambient accent without
the 5KB ColorThief dependency. Same-origin cover URLs so no CORS
dance. Result cached by URL so revisits are free.
PlayerBar samples the current track's cover and pipes the resulting
rgb into a 2px accent strip above both the compact and desktop
variants. Transparent until the first resolve; 300ms transition on
colour change so track-skips fade rather than snap.
#10 — slight bump to Home tile widths so a typical viewport shows
roughly 5-6 across instead of cramming 8-9: Playlists w-56, all
remaining AlbumCard rows w-48 (Recently Added + both Rediscover
album scrollers). Replaces the wave-2 sizes that were still showing
7-8 across on wider screens.
#6 — Adds a 2-up grid above the Playlists carousel:
- Today's pick: For-You playlist card with a Play button that
invokes the system-shuffle endpoint (same path as PlaylistCard
uses for the play overlay).
- Just added: most recently added album card with a Play button
that fetches the album detail and queues the tracks.
Both cards share HomeHeroCard.svelte — cover left, eyebrow + title
+ subtitle + Play right, on a from-accent/15 → surface gradient so
the eye lands here before scrolling into the carousels. Each card
links to its detail page; the Play button is event-stopped so it
plays in-place. md:grid-cols-2 stacks on narrow viewports.
Only renders when at least one of the two pieces of data is
available so the page degrades cleanly on fresh installs.
The new hero card on Home renders the For-You playlist twice — once
at the top in the featured row and once in the Playlists carousel.
The two existing assertions that did screen.getByText('For You')
now match both and fail. Wrap each in within(playlistsSection) so
the test targets the carousel render specifically.
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.
13 commits since the last main merge. All CI green on the tip (
682d7a5e, run 148).Android (7 commits)
User-reported issues from device testing, all fixed:
PlayerController.uiState+AuthStore.cacheSettingsand pre-downloads the next-N tracks (default 5) into the sharedSimpleCachevia Media3'sCacheWriter. Forward skips on a slow network now play from disk instead of stalling on a fresh HTTP connection.CacheSettings.prefetchWindowwas shipped at default=5 since M8 but the prefetcher itself was deferred — this closes that gap.Column.verticalScrollwon every drag delta before the outerdetectVerticalDragGesturessaw it. Replaced with aNestedScrollConnection.onPostScrollthat accumulates downward over-scroll at the top of the column toward a 200 px threshold. Same primitive Material'sModalBottomSheetuses.Crossfadewas keyed on the state INSTANCE, and because each section's flow emission creates a newUiState.Success(data), every per-section hydration tick re-ran the 300 ms fade. Keyed onstate::classinstead; Loading/Success/Empty/Error class transitions still animate, Success→Success recompositions update the LazyColumn normally.Box-based fill. No scrubber thumb on the mini bar. Tapping still expands to NowPlaying where scrubbing lives.SliderDefaults.Thumb.thumbSize. Spacers above/below shrunk to 4 dp.rememberSmoothPositionMshelper.produceStatekeyed on(positionMs, isPlaying, durationMs)resets to the canonical value on each polling tick and runs awithFrameMillisloop advancing at 1 ms/ms in between. Both MiniPlayer and NowPlaying consume the smoothed value — no more 500 ms steps.Web UI flavor pass (6 commits, 10 items)
Per operator screenshot review — wall of identical tiles → designed page with rhythm:
CardActionCluster).after:bg-accent/60).color-mix(fs-iron 60%, transparent)).from-accent/15 → surfacegradient. Anchors the page.w-56→ Recently addedw-48→ Rediscoverw-40. Freshest content gets the biggest tiles.shadow-sm→shadow-lg+ring-accent/40on:group-hoverfor all album tiles.APK channel
android.ymlwas simplified to testing-only earlier this session; the signed-release build moved intorelease.yml'sandroid-releasejob, chained toimage-releasevianeeds:. Main pushes still skipandroid-releaseby design, so this merge to main builds a fresh:latestimage but the bundled APK stays at whatever the last tag-build attached. Force-movingv2026.06.01after device-testing this PR will rebuild the APK and bundle it.Previously Recently Added chunked into rows of 25 and rendered each chunk as its own LazyRow, so chunks scrolled independently. Same pattern Most Played uses (one LazyHorizontalGrid where all rows scroll as a single panel) now applies to Recently Added. - New RECENTLY_ADDED_GRID_ROWS = 2 + RECENTLY_ADDED_GRID_HEIGHT_DP = 440 (matches a 200dp tile × 2 rows + the 8dp inter-row gap). - New RecentlyAddedGrid composable owns the section header + the LazyHorizontalGrid. Column-major re-flattening matches Web's row-major reading order on screen (top row first, then bottom). - recentlyAddedSection collapses from itemsIndexed-over-chunks to a single item { RecentlyAddedGrid(...) } in the outer LazyColumn. - AlbumsRow stays untouched (still used by Rediscover, etc.). The other multi-section helpers (Rediscover, Last Played, Playlists) are single-row by design and don't need this treatment.The new hero card on Home renders the For-You playlist twice — once at the top in the featured row and once in the Playlists carousel. The two existing assertions that did screen.getByText('For You') now match both and fail. Wrap each in within(playlistsSection) so the test targets the carousel render specifically.