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

13 Commits

Author SHA1 Message Date
bvandeusen 682d7a5ec4 test(web): scope home For-You assertions to Playlists section
test-web / test (push) Successful in 33s
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.
2026-06-01 20:12:59 -04:00
bvandeusen 17b276b5f5 feat(web): hero row at top of Home (For You + most recently added)
test-web / test (push) Failing after 36s
#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.
2026-06-01 20:10:47 -04:00
bvandeusen 5f297c4c21 feat(web): dominant-color accent strip on PlayerBar + bigger Home tiles
test-web / test (push) Successful in 36s
#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.
2026-06-01 20:03:48 -04:00
bvandeusen 5b25f89c01 test(web): update AlbumCard test for dropped year line
test-web / test (push) Successful in 32s
2026-06-01 19:59:40 -04:00
bvandeusen a92f2c0121 feat(web): radial gradient + playlist title overlay + tile-size hierarchy
test-web / test (push) Failing after 33s
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.
2026-06-01 19:57:05 -04:00
bvandeusen c9afd4f584 feat(web): card hover-reveal + drop year + accent rule on section headers
test-web / test (push) Failing after 33s
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.
2026-06-01 19:54:16 -04:00
bvandeusen d99de1af27 feat(android): Recently Added → single LazyHorizontalGrid (#77)
android / Build + lint + test (push) Successful in 4m14s
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.
2026-06-01 19:30:17 -04:00
bvandeusen 8ecb2cf553 feat(android): MiniPlayer fill bar + slim NowPlaying scrubber + smooth playhead
android / Build + lint + test (push) Has been cancelled
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.
2026-06-01 19:27:17 -04:00
bvandeusen c23df8d8af fix(android): stop Home Crossfade firing on every section emission (#1)
android / Build + lint + test (push) Successful in 3m55s
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.
2026-06-01 19:17:11 -04:00
bvandeusen 3d52f271a0 fix(android): collapse onPostScroll branches (detekt ReturnCount)
android / Build + lint + test (push) Has been cancelled
2026-06-01 19:14:39 -04:00
bvandeusen 2b9b4c1db6 fix(android): NowPlaying drag-down dismiss via NestedScroll (#2)
android / Build + lint + test (push) Failing after 1m25s
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).
2026-06-01 19:09:59 -04:00
bvandeusen 5366cd5634 fix(android): merge AudioPrefetcher continue guards (detekt LoopWithTooManyJumpStatements)
android / Build + lint + test (push) Has been cancelled
2026-06-01 19:07:43 -04:00
bvandeusen 8df41c3bed feat(android): AudioPrefetcher pre-downloads next-N tracks into SimpleCache
android / Build + lint + test (push) Failing after 1m31s
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.
2026-06-01 19:02:27 -04:00