Android v1 polish + Web UI flavor pass #65

Merged
bvandeusen merged 13 commits from dev into main 2026-06-01 20:17:49 -04:00
Owner

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-smshadow-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.

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.
bvandeusen added 13 commits 2026-06-01 20:17:35 -04:00
feat(android): AudioPrefetcher pre-downloads next-N tracks into SimpleCache
android / Build + lint + test (push) Failing after 1m31s
8df41c3bed
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.
fix(android): NowPlaying drag-down dismiss via NestedScroll (#2)
android / Build + lint + test (push) Failing after 1m25s
2b9b4c1db6
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).
fix(android): collapse onPostScroll branches (detekt ReturnCount)
android / Build + lint + test (push) Has been cancelled
3d52f271a0
fix(android): stop Home Crossfade firing on every section emission (#1)
android / Build + lint + test (push) Successful in 3m55s
c23df8d8af
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.
feat(android): Recently Added → single LazyHorizontalGrid (#77)
android / Build + lint + test (push) Successful in 4m14s
d99de1af27
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.
bvandeusen merged commit 9440c5860b into main 2026-06-01 20:17:49 -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#65