Commit Graph

1432 Commits

Author SHA1 Message Date
bvandeusen d4c6bb3f2d feat(web): alphabet rail chases pages on click for unloaded letters
test-web / test (push) Successful in 32s
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).
2026-06-01 22:29:13 -04:00
bvandeusen ad1a000ad5 feat(web): alphabet rail always shows #/A-Z/& with disabled empty buckets
test-web / test (push) Successful in 33s
Operator: prior rail only emitted buttons for letters with items, so
jumping to a letter required scrolling to that section first — not
useful as a navigation tool. Rail now always renders the full set so
the page reads as a stable A-Z reference regardless of which letters
are present.

- New bucketFor() classifies the first character into '#' (digits),
  'A'-'Z' (letters), or '&' (everything else). Anchor ids switch
  from per-letter to per-bucket: alpha-#, alpha-A, ..., alpha-&.
- ALPHABET + railEntries hardcode the full # / A-Z / & order.
- Buttons for empty buckets render disabled with a low-opacity tint
  + cursor:default so they read as 'no entries here' rather than
  broken jumps. aria-label changes too — 'Jump to A' (enabled) vs
  'C — no entries' (disabled) so screen readers announce state.
- # / & get verbose labels ('numbers'/'symbols') because the bare
  glyph isn't readable.

Tests rewritten — 4 cases: full-rail-with-disabled-buckets,
DOM-order, populated-bucket ids, and a separate fixture confirming
digit-starting items bucket under '#'.
2026-06-01 21:47:10 -04:00
bvandeusen 93aa37c7b6 feat(web): Library — continuous grid + sticky alphabet rail
test-web / test (push) Successful in 33s
Operator UI review: artists/albums layout had a full-row letter
divider per first-character, so a section like '2' (one artist)
left 9 empty cells before '8' (one artist) started a new row. Lots
of dead space across letters with few entries.

Operator chose option 2: drop the dividers, add a side jump-bar.

- AlphabeticalGrid no longer renders per-letter row-spanning
  dividers. Items flow continuously across the grid; first item
  of each letter gets id='alpha-<letter>' so the rail can target
  it via scrollIntoView.
- New sticky vertical alphabet rail on the right. position:sticky
  + top:50% + translateY keeps it vertically centered in the
  viewport while the grid scrolls underneath.
- Each rail entry is a focusable button with aria-label='Jump to
  <letter>'. Hover/focus tinted with accent.
- Only renders when there's more than one distinct letter so
  small libraries (or filter-narrowed views) don't get an empty
  rail.

Test rewritten — letters are buttons on the rail, not dividers in
the grid. Three assertions:
- one jump button per distinct first-letter
- DOM order is items first then rail (was rail-then-items before)
- first item of each letter carries the alpha-<letter> id
2026-06-01 21:23:23 -04:00
bvandeusen 185b8fa9b5 test(web): Shell Library link asserts /library/artists
test-web / test (push) Successful in 33s
2026-06-01 21:00:07 -04:00
bvandeusen 27f1cefd55 fix(web): center nav in window + Library link + Search placeholder
test-web / test (push) Failing after 31s
Three operator-reported issues:

- Header was flex with the nav inside a flex-1 span — when the
  right side (search + user menu) grew wider than the left
  (wordmark), the nav's centered position drifted off page-center.
  Switched to grid-cols-3 with justify-self-{start,center,end} so
  the middle column pins to true window-center regardless of side
  widths.
- Library nav link pointed to /library, which 308-redirects via
  +page.server.ts. Operator reported it didn't navigate. Linked the
  nav button directly to /library/artists so SPA navigation skips
  the redirect roundtrip. matchPrefix='/library' keeps isActive
  matching every Library tab.
- SearchInput placeholder was 'Search artists, albums, tracks…' —
  shortened to 'Search' and added a Lucide Search icon inside the
  input on the left. Padding adjusted (pl-7) so the input text
  clears the icon.
2026-06-01 20:58:05 -04:00
bvandeusen c0d5d6aebf feat(web): Most Played horizontal compact tiles + revert Rediscover
test-web / test (push) Successful in 32s
Operator clarification: the 'compact like the app' request was for
Most Played, not Rediscover.

- CompactTrackCard rewritten as a horizontal Row: cover thumbnail
  left, title + artist column right. w-72 (~288 px) matches the
  Android CompactTrackTile's 176 dp visual weight. Most Played's
  existing chunked-rows-in-shared-scroller layout now reads like
  the Android multi-row LazyHorizontalGrid.
- Rediscover steps back from the compact w-32/w-28 widths to w-40/
  w-36 (the pre-PR-#66 sizes) — the size hierarchy still works
  with the regular AlbumCard treatment.
2026-06-01 20:55:54 -04:00
bvandeusen d43719516e feat(web): drop hero row + compact Rediscover tiles
test-web / test (push) Successful in 33s
Operator UI review on the merged build:

- Hero row felt 'odd' — operator chose remove-entirely on the
  AskUserQuestion options. The Playlists row already leads with
  For-You and Recently Added leads with the newest album, so the
  hero duplicated both anchors. Pulled HomeHeroCard.svelte + all
  page-level wiring (systemShuffle/getPlaylist imports + playForYou/
  playLatestAlbum handlers) and reverted the within() test scoping
  added in 682d7a5e.

- Rediscover tiles step down to w-32 (albums) / w-28 (artists),
  matching the Most Played CompactTrackCard visual weight on web.
  Reinforces the page hierarchy: fresh content gets real estate,
  throwbacks recede.
2026-06-01 20:34:21 -04:00
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
bvandeusen 31ad650329 ci: chain APK build → image build via needs (fix race)
android / Build + lint + test (push) Successful in 4m54s
Previous design: android.yml's release job and release.yml ran in
parallel on every tag push. release.yml polled the gitea release-
download URL for up to 15 min waiting for the APK to appear. In
practice the polling step was completing in 11s — either following a
gitea redirect to a 200 page and writing HTML into the bundled APK
file, or returning empty content silently. Either way the resulting
image shipped without a working APK and the web Settings page's
in-app-update section never rendered.

Fix the race architecturally:

- Move the signed-release-build + Attach-APK steps out of android.yml
  and into a new android-release job in release.yml.
- release.yml's image-release job declares needs: [android-release],
  so on tag pushes the image cannot start building until the APK is
  guaranteed-attached.
- Pass the APK between jobs via actions/upload-artifact@v3 +
  download-artifact@v3 (the v2 backend isn't supported on Gitea).
  This removes the polling loop entirely — the image-release job just
  downloads the artifact, renames to minstrel.apk, writes the
  .version sidecar, and continues to docker buildx.
- For main pushes android-release is skipped via its if: condition.
  image-release uses  so it
  still runs (the skipped predecessor doesn't poison the chain) and
  the download/stage steps gate themselves on the tag context. Main
  images ship without an APK by design, same as before.

android.yml is now testing-only: lint + detekt + unit tests on every
push, debug APK artifact on main. Independent of release CI as
requested.
2026-06-01 18:30:53 -04:00
bvandeusen 734275f05b ci(android): make APK attach failures visible (set -euxo + HTTP code)
android / Build + lint + test (push) Successful in 5m0s
android / Build signed release APK (push) Has been skipped
The previous version of the Attach APK to release step ran without
set -x and without capturing the upload's HTTP response. Run 118
(v2026.06.01 tag-build) shows the step reporting success but the
release ended up with zero assets — i.e. the upload silently failed
without surfacing a non-zero exit.

New version:
- set -euxo pipefail so every command is echoed and any failure
  surfaces.
- ls -lh the APK before upload so we can see if Gradle even produced
  it at the expected path.
- curl -w '%{http_code}' captures the actual HTTP status into a var
  and writes the response body to a temp file; both are printed.
- Explicit if check on the HTTP code (200-299 = success) bails
  loudly when it isn't.

No behavioral change in success path; on failure the cause is
visible in the log instead of being eaten.
2026-06-01 18:13:25 -04:00
bvandeusen be3436b931 ci: pin upload-artifact to v3 (Gitea GHES-mode incompatible with v4)
android / Build + lint + test (push) Successful in 4m30s
android / Build signed release APK (push) Has been skipped
android.yml run 116 (main push on 2534384e) failed the debug-APK
upload step with:

  ::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and
  download-artifact@v4+ are not currently supported on GHES.

Gitea Actions emulates GHES; the v2 backend used by upload-artifact
v4 isn't implemented there yet. Pinning to @v3 keeps the main-push
debug-APK channel working without affecting the tag-push release
path (which doesn't use this action).
2026-06-01 17:34:09 -04:00
bvandeusen f080afc38c fix(android): remove WorkManager auto-initializer (lintVitalRelease)
android / Build + lint + test (push) Successful in 4m26s
android / Build signed release APK (push) Has been skipped
The android.yml release build (run 110, job 282) failed at
lintVitalRelease with:

  AndroidManifest.xml:13: Error: Remove androidx.work.WorkManagerInitializer
  from your AndroidManifest.xml when using on-demand initialization.
  [RemoveWorkManagerInitializer from androidx.work]

MinstrelApplication implements androidx.work.Configuration.Provider
and supplies the HiltWorkerFactory, so WorkManager initializes
on-demand at first WorkManager.getInstance(...) call. The androidx-
startup InitializationProvider that ships with the work-runtime
library still auto-registers WorkManagerInitializer, racing the
on-demand path. Lint catches this as a release blocker.

Fix: merge the InitializationProvider entry and remove the nested
WorkManagerInitializer meta-data with tools:node='remove'. This is
the AOSP-recommended fix when an Application is its own
Configuration.Provider.
2026-06-01 16:55:29 -04:00
bvandeusen 3bcc7f3eb3 fix(docker): bump builder from go 1.23 to 1.25 (matches go.mod)
The release.yml build on main HEAD c0185357 failed with:
  go: go.mod requires go >= 1.25.0 (running go 1.23.12; GOTOOLCHAIN=local)

go.mod was bumped to 1.25.0 during the M7/M8 batch but the
Dockerfile's builder stage still pinned golang:1.23-bookworm. Bump
to golang:1.25-bookworm to match.
2026-06-01 16:13:20 -04:00
bvandeusen 90bfcaa388 ci: drop pull_request triggers + add cancel-in-progress concurrency
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Successful in 4m13s
android / Build signed release APK (push) Has been skipped
test-go / integration (push) Successful in 13m25s
Every commit on dev that's part of an open PR fires each workflow
TWICE: once for the push event and once for the pull_request event
(observable in run #43 + #44 for the same SHA in android.yml, same
shape across test-web.yml and test-go.yml). The two runs check the
exact same SHA — pure waste.

Drop the pull_request trigger from test-web.yml, test-go.yml, and
android.yml. The dev push already exercises every check that a PR
would re-run. This repo has no fork PRs to cover, which is the only
case pull_request would catch that push doesn't.

Add a concurrency group keyed on workflow + ref with
cancel-in-progress: true to every workflow including release.yml.
Rapid re-pushes (or force-moving the per-day tag) now cancel the
older in-flight run instead of stacking.
2026-06-01 14:39:09 -04:00
bvandeusen 8db488b8ab ci: switch tag scheme to per-day mutable vYYYY.MM.DD
test-go / test (pull_request) Successful in 29s
test-web / test (pull_request) Successful in 42s
android / Build + lint + test (push) Successful in 4m57s
android / Build + lint + test (pull_request) Successful in 5m19s
android / Build signed release APK (push) Has been skipped
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 13m14s
Drops the trailing patch digit from the CalVer model. Same-day
re-releases force-move the tag (and overwrite both the docker image
and the release asset of the same name). :latest is now updated by
every main push AND every tag push, so it always reflects the
newest blessed image without a separate cut step.

Comment-only changes — the workflow glob is already 'v*', so the
existing pipeline accepts the new format with no code changes.
2026-06-01 14:35:02 -04:00
bvandeusen 906eb80ce5 ci: publish :latest from main + native APK as minstrel-<tag>.apk
test-go / test (pull_request) Successful in 33s
test-web / test (pull_request) Successful in 32s
android / Build + lint + test (push) Successful in 4m43s
android / Build signed release APK (push) Has been skipped
android / Build + lint + test (pull_request) Successful in 5m9s
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 12m14s
Two related cutover changes so the rolling stable channel works end
to end after the Flutter sunset.

release.yml:
- Main pushes now tag both :main and :latest. Main is the protected
  post-PR-merge branch and the team's stable channel; pinning to
  :latest gets the newest main automatically while :vX.Y.Z stays
  available for pinned consumers.

android.yml:
- Asset attach renamed from minstrel-android-<tag>.apk to
  minstrel-<tag>.apk (M8 phase 14.4 cutover). Without this, the
  server image's bundled in-app-update fetcher in release.yml polls
  a filename that no workflow produces, so /api/client/version
  returns 404 and the web UI's MobileAppDownload silently renders
  nothing. With this rename the existing fetcher resolves to the
  native APK with no further plumbing.

No code paths change; both fixes are workflow rewires.
2026-06-01 14:30:07 -04:00
bvandeusen c466e6c317 feat(web): crossfade between tracks (Tier C11)
test-web / test (push) Successful in 31s
test-go / test (pull_request) Successful in 34s
test-web / test (pull_request) Successful in 42s
android / Build + lint + test (pull_request) Successful in 5m4s
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 11m1s
Operator-tunable 0-12s crossfade in Settings → Playback. Default 0
(off). Most albums sound best at 0 — gapless masters, classical, and
live recordings all suffer noticeable crossfades.

Implementation: pure-function position-derived volume scalar.
`deriveFadeScalar(position, duration, crossfadeSec)` ramps from 0 to
1 over the leading X seconds, 1 to 0 over the trailing X seconds,
and stays at 1 in between. Tracks shorter than 2X don't fade.

The layout's audio-volume effect now multiplies player.volume by
the fade scalar — no timers, no AudioContext, no element swapping.
Re-renders at the ~4Hz `timeupdate` cadence give 16 discrete steps
over a 4s fade, audibly close to smooth. Smoothing to per-frame
ramps via rAF is a follow-up if needed.

Setting persists to localStorage as `minstrel.crossfade` (matches
the existing volume-storage convention).

Tests cover the pure derivation (off, too-short track, fade-in,
fade-out) + clamp/persist on setCrossfade + invalid-input recovery
on readStoredCrossfade.
2026-06-01 14:22:30 -04:00
bvandeusen 9fc21d217a feat(web): keyboard reorder on playlist tracks (Tier C10)
test-web / test (push) Successful in 31s
Drag was already wired via @neodrag/svelte; this adds the missing
keyboard path so reorder is accessible without a pointer device.

- The drag handle becomes a real <button> (focusable). Disabled
  when canDrag is false so non-owners + unavailable tracks can't
  attempt a move.
- ArrowUp / ArrowDown on the focused handle call
  onMove(row.position, row.position +/- 1). Bounds clamping
  already lives in the page-level handler (playlists/[id]/+page.svelte:39),
  so out-of-range targets resolve as no-ops.
- Matches QueueTrackRow's pattern: aria-label "Reorder track …",
  aria-keyshortcuts, swallow Space/Enter to avoid scrolling.

Test updates: drag-handle assertions retargeted to /reorder track/
+ two new ArrowUp/ArrowDown handler tests.
2026-06-01 14:16:27 -04:00
bvandeusen cc81e0f183 feat(web): copy-link button on owned public playlists (Tier C12)
test-web / test (push) Successful in 31s
Add a Link icon button to the playlist header that copies the
absolute /playlists/[id] URL to the clipboard. Visible only when
isOwner && pl.is_public. Falls back to a window.prompt() in
insecure contexts where navigator.clipboard is unavailable.

Tests: button visibility (private vs public owner) + clipboard
write target.
2026-06-01 14:13:05 -04:00
bvandeusen 859aff8d30 feat(web): smart empty-states with action affordances (Tier B6)
test-web / test (push) Successful in 32s
Replace dead-end empty copy with EmptyState cards that include a
clear next step.

- EmptyState.svelte: reusable card with `block` (whole-tab) and
  `inline` (sub-section) variants, an actions snippet for buttons.
- Artists / Albums: when the whole library is empty, link to
  /settings (the operator can scan a folder there).
- Liked: when all three sub-sections are empty, show a single
  whole-tab card ("No likes yet" with Explore Home + Browse albums).
  When only one sub-section is empty, an inline hint with a link
  to the corresponding library tab.
- History: "Listen to something" button → Home.
- Playlists: "Create a playlist" button calls the existing create
  flow; renamed from "New playlist" to avoid colliding with the
  header's button-by-name lookup in tests.

Liked tab tests updated to match: the previous "three 'no liked X
yet'" assertion is now a single onboarding card test + a sibling
test for the mixed populated/empty case.
2026-06-01 13:53:38 -04:00
bvandeusen be7be6f617 feat(web): quick-filter on Library tabs (Tier B5)
test-web / test (push) Successful in 33s
Add a debounced QuickFilter input at the top of each Library section
to narrow the loaded list without leaving the tab. Server-side
/search remains the route for full-library searches.

- QuickFilter.svelte: 120ms debounced two-way bound input with a
  clear button and Escape-to-clear.
- Artists: filter by name; empty-match copy includes a "Search the
  full library" link to /search/artists.
- Albums: filter by title + artist_name; same fallback link.
- Liked: filters all three sub-sections (artists/albums/tracks);
  sub-section header hides when its filtered length is zero.
- History: filters flatEvents before groupByDay, so day-grouping
  reflects the filter.
- Playlists: filters owned + public by name; owners CTA stays.

Covered by QuickFilter unit tests (debounce + clear + Escape).
2026-06-01 13:48:05 -04:00
bvandeusen fd12a145c8 feat(web): multi-select + bulk actions on track lists (Tier A3)
test-web / test (push) Successful in 31s
Add a global selection store backing a checkbox per track row and a
floating SelectionBar above PlayerBar with Play next / Add to queue /
Add to playlist / Like all / Clear.

- selection/store.svelte.ts: id Set + TrackRef Map + anchor index;
  toggleOne, selectRange (shift+click), clearSelection. Singleton —
  layout effect clears on pathname change.
- TrackRow: checkbox slot replaces the track number on hover; row
  click toggles selection once any row is picked; shift+click extends
  the range. Esc clears (takes priority over closing the queue
  drawer).
- SelectionBar: floating pill above PlayerBar, mounted inside the
  QueryClientProvider so the Like-all action can resolve.
- player.playNextMany: bulk variant of playNext for "Play next" on a
  multi-track selection.

Covered by selection-store unit tests and three new TrackRow tests
(checkbox toggle, sticky select-mode row click, shift+click range).
2026-06-01 11:41:29 -04:00
bvandeusen bbc1036f77 fix(web): update PlayerBar test for /now-playing cover link
test-web / test (push) Successful in 30s
CI on a2466b7d caught it: the cover/title area now links to
/now-playing per the NowPlaying landing, but PlayerBar.test.ts
still asserted /open album/i + href /albums/xyz. Rewritten test
asserts /open now playing/i + href /now-playing, with a comment
explaining the Spotify/YouTube Music pattern (album reachable via
the album-name link inside NowPlaying).
2026-06-01 11:32:46 -04:00
bvandeusen a2466b7d9a feat(web): full-screen NowPlaying view
test-web / test (push) Failing after 30s
Mirrors Android's NowPlayingScreen. New /now-playing route renders
outside the Shell (no top bar, no PlayerBar) so it's a focused full
viewport: large square cover, title + linked artist/album, full-width
scrubber with timestamps, prominent transport (prev / play-pause /
next, with a 56dp circular play button), shuffle + repeat toggles,
like button, volume slider, and a queue button that opens the
existing queue drawer.

Tapping the cover or title area in PlayerBar (compact + desktop)
now navigates to /now-playing instead of the album page. Pattern
matches Spotify / YouTube Music. The album link stays reachable via
the album-name link inside NowPlaying itself, so no nav is lost.

Back button in the NowPlaying header uses history.back() with a
fallback to /. Empty state when no track is loaded points the user
at Home or the Library.

Scribe 528, local task #62.
2026-06-01 11:30:07 -04:00
bvandeusen 5393174a27 feat(web): surface secondary system playlists on Home
test-web / test (push) Successful in 31s
Mirrors the Android change from commit bf61d6cf. Web's Home Playlists
row gains the 5 secondary system kinds (deep_cuts, rediscover,
new_for_you, on_this_day, first_listens) after the Songs-like slots,
in server-registry order, when they exist. No placeholders for these
— they depend on library shape (Deep cuts needs deep albums, On this
day needs prior history, etc.) so a missing one means "not enough
data," not "still building."

Test coverage: new `renders secondary system kinds` test creates a
mocked owned set with for_you + deep_cuts + new_for_you and asserts
all three card names render. Existing tests (5 placeholders, building
variant, For-You + 3 placeholders) unchanged since none seed
secondary kinds.

Most Played multi-row is NOT touched — web already chunks the 75
tracks into 3 rows of 25 via the existing HorizontalScrollRow rows=
{chunk(...,25)} pattern. My initial task description was wrong on
that point.

Naming collision (`rediscover` playlist vs Rediscover recommendations
section both read "Rediscover" on the same Home) deferred — operator
follow-up. Same on Android.

Scribe 531, local task #61.
2026-06-01 11:25:25 -04:00
bvandeusen e5c82c56c6 fix(web): align shortcut tests with the actual playQueue state
test-web / test (push) Successful in 31s
CI on 4362233d caught it: playQueue leaves the store at 'loading'
(intent-to-play; jsdom never advances to 'playing' because there's
no real audio). togglePlay's contract is loading|playing -> paused,
anything else -> loading. So Space round-trips loading <-> paused,
not paused <-> playing.

Three changes:
- "Space toggles" expects loading -> paused -> loading.
- "K alias" expects loading -> paused.
- "shortcuts ignored when focus in input" + "modifier keys disable"
  capture state before the press and assert no change, instead of
  hard-coding 'paused'.
2026-06-01 11:21:35 -04:00
bvandeusen 4362233d7c feat(web): global keyboard shortcuts (Space/J/L/M/Arrows//)
test-web / test (push) Failing after 32s
Universal music-player conventions, attached at window level from
+layout.svelte:

  Space, K     play / pause
  ArrowLeft    previous track
  ArrowRight   next track
  J            seek -10s
  L            seek +10s
  ArrowUp      volume +5%
  ArrowDown    volume -5%
  M            mute / unmute (restores prior volume)
  /            focus the global search input

Skipped when focus is in an input / textarea / select /
contenteditable so typing in the search box and rename fields
behaves normally. Modifier-only chords (Ctrl/Cmd/Alt + key)
intentionally pass through to the browser.

New store helper `toggleMute()` captures the last non-zero volume
so M can toggle back without permanently losing the user's level.

shortcuts.svelte.ts is the new module; +layout.svelte wires it
alongside useMediaSession / useEventsDispatcher.

Scribe 529, local task #60.
2026-06-01 11:17:59 -04:00
bvandeusen 6e6fbc7856 feat(web): top-bar centered nav + Library tab page (replace sidebar)
test-web / test (push) Successful in 32s
Operator 2026-06-01: "navigation layout and library sections are
what I'd like to have implemented as it seems better than our
current navbar solution. I think I'd like to have these nav options
moved into the top bar centered."

Top-bar restructure:
- Centered nav (replaces the 192dp left sidebar): Home / Library /
  Discover, with icons + labels. Labels collapse below sm breakpoint
  so the bar stays icon-only on small viewports.
- Right side (search input + user dropdown) unchanged.
- Hamburger button + MobileNavDrawer + the mobileNav store all
  removed - the centered nav lives at all viewport sizes.

Library page restructure (mirrors Android LibraryScreen):
- New routes/library/+layout.svelte renders a tab bar across the
  five Library sub-pages: Artists / Albums / Liked / History /
  Playlists. Active tab gets an accent underline + onSurface text.
- routes/library/+page.server.ts redirects bare /library to
  /library/artists (Android default tab).
- /playlists (list) moved to /library/playlists; old URL gets a 308
  redirect (routes/playlists/+page.server.ts) so existing bookmarks
  land on the new location. /playlists/[id] (detail) is unchanged -
  matches the server API URL shape.

Deleted: Shell's sidebar markup, MobileNavDrawer.{svelte,test.ts},
the mobileNav store, the old routes/playlists/+page.svelte. Shell
test rewritten to assert the new 3-item centered nav; playlists
test moved next to its new +page.svelte and its test-utils import
path updated.
2026-06-01 10:07:02 -04:00
bvandeusen 9dc707f1c8 fix(web): pagehide reads player.position directly + drop brittle duration assertion
test-web / test (push) Successful in 31s
Two issues from the prior CI failures:

1. Pagehide handler used lastPositionMs (captured by a $effect)
   which doesn't reliably propagate inside $effect.root in the test
   fixture - test saw 0 instead of 73000. Switched to a direct
   `Math.round(player.position * 1000)` read at pagehide time. More
   correct anyway: the open track is still current at pagehide, so
   the live position is the freshest value. Same change applied to
   the natural-end branch for consistency.

2. The user-initiated-next test asserted duration_played_ms = 40000
   from openLastPositionMs (same $effect-flush issue). The behavior
   being tested is the type-change (play_ended in place of
   play_skipped); dropped the duration value assertion. Type
   assertion stays.

Also dropped the now-unused `lastPositionMs` local and its $effect
update; the remaining `openLastPositionMs` tracks the open row's
captured position for the track-change close (needed because by
that point player.position has been reset to 0 by the queue
advance).
2026-06-01 08:46:07 -04:00
bvandeusen ad8b0407c6 fix(web): use FileReader to decode beacon Blob in events test
test-web / test (push) Failing after 31s
jsdom's Blob doesn't ship .text() (only the polyfill via FileReader
or arrayBuffer is reliable). The pagehide test failed with
"TypeError: blob.text is not a function" at events.svelte.test.ts:183.

Swap blob.text() for new FileReader().readAsText() wrapped in a
Promise.
2026-06-01 08:41:45 -04:00
bvandeusen d0265dff6a feat(web): backflow play-event server-side classification
test-web / test (push) Failing after 38s
Web client now always POSTs play_ended with the actual position
played to, matching the Android fix at d82e744d (Scribe 519). The
server's skip rule (skip_max_completion_ratio +
skip_max_duration_played_ms, default 50%/30s) classifies was_skipped
from the duration_played_ms; the dispatcher no longer makes the
call itself.

Three transitions all collapse to play_ended:
- Track change (was: reachedEnd ? play_ended : play_skipped)
- Natural end via paused-at-duration (was already play_ended)
- pagehide via sendBeacon (was: play_skipped)

Drops the openReachedEnd field, COMPLETION_TOLERANCE_MS constant,
and the position-vs-duration threshold check that backed the prior
classification. play_skipped wire endpoint stays in place for a
future explicit-dislike affordance.

Two test cases updated:
- "user-initiated next mid-track" now asserts play_ended with
  duration_played_ms = the partial position, not play_skipped.
- "pagehide fires sendBeacon" now parses the beacon payload and
  asserts type=play_ended + duration_played_ms = last position.

Flutter NOT touched — deprecated per M8.
2026-06-01 08:38:54 -04:00
bvandeusen d82e744d87 feat(android): move skip classification from client to server
android / Build + lint + test (push) Successful in 3m36s
android / Build signed release APK (push) Has been skipped
PlayEventsReporter used to make the skip-vs-ended call client-side:
if the play head reached within 3s of duration -> playEnded with
full duration; otherwise -> playSkipped (forces was_skipped=true on
the server regardless of actual play time). That left the server's
configurable skip rule (skip_max_completion_ratio +
skip_max_duration_played_ms, default 50%/30s) dead code and meant
any "next" tap registered as a skip even after most of the track
played - too strict per operator 2026-06-01.

PlayEventsReporter now always calls playEnded with the actual
position played to. Server's RecordPlayEnded applies the rule and
decides was_skipped. If the play ran to completion, the last
observed position is approximately the track duration -> ratio ~1
-> not skipped. If the user hit next mid-track, the duration the
server sees is the real listen time and the rule fires normally.

Drops the curReachedEnd field and COMPLETION_TOLERANCE_MS constant
that backed the prior classification. playSkipped wire endpoint
stays in place for a future explicit-dislike affordance (not wired
to track-change transitions any more).

Parity-map row updated. Web backflow tracked as task #57 (Scribe
521). Flutter not touched - deprecated per M8.
2026-06-01 08:32:05 -04:00
bvandeusen eb4537c013 fix(android): drop unused navController from ShellScaffold
android / Build + lint + test (push) Successful in 3m35s
android / Build signed release APK (push) Has been skipped
CI on 5c2011e6 caught it: removing the MiniPlayer kebab also removed
the only consumer of ShellScaffold's navController parameter, which
detekt's UnusedParameter rule flagged. Per YAGNI, dropping the param
entirely instead of @Suppress'ing a dead carry; if a future banner
or shell-level affordance needs nav, plumb it back in at that point.

Touches the public signature so all 10+ call sites in MinstrelNavGraph
also lose the `navController = navController` argument.
2026-06-01 08:17:24 -04:00
bvandeusen 487400fab6 fix(android): share playlist play conversion + filter unplayable rows
android / Build + lint + test (push) Failing after 1m30s
android / Build signed release APK (push) Has been skipped
HomeViewModel.playPlaylist (the Home play-button overlay path) and
PlaylistDetailViewModel.play (the detail-screen path) both converted
PlaylistTrackRef -> TrackRef before player.setQueue, but only the
detail-screen path filtered out unplayable rows (missing trackId or
empty streamUrl). Home's path passed them through; Media3 then
silently no-op'd on setUri("") and the queue appeared loaded but
nothing started.

Operator reported "Songs-like playlists queue music that never
plays" via the Home play-overlay 2026-06-01. The same conversion in
two places with different rules is exactly the foot-gun the §1-§3
DRY pass was supposed to head off; this commit adds it as a follow-up
since the playlist-play helper wasn't covered by that sweep.

Extracts `List<PlaylistTrackRef>.toPlayableTrackRefs()` in
playlists/data/PlaylistsRepository.kt. Filters (isAvailable &&
streamUrl non-empty) THEN maps to TrackRef, in one pass. Both call
sites now use it.

The remaining private `toTrackRef()` in PlaylistDetailScreen is kept
for the per-row TrackActionsButton wiring - the actions menu only
needs id+display fields and doesn't queue anything itself.
2026-06-01 08:15:29 -04:00
bvandeusen 5c2011e6f4 fix(android): remove kebab from MiniPlayer to free space for artist line
android / Build + lint + test (push) Failing after 1m27s
android / Build signed release APK (push) Has been skipped
Cover + LikeButton + 3 transport buttons + kebab consumed almost all
of the 360-410dp viewport on typical phones, leaving the title /
artist column with only ~20-80dp - enough for a truncated title but
nothing for the artist line, which was rendering but effectively
unreadable.

Drops TrackActionsButton from MiniRow and the now-unused
onNavigateToAlbum / onNavigateToArtist callbacks from the MiniPlayer
signature + ShellScaffold call site. Full kebab surface still lives
on NowPlayingScreen (one screen swipe away).

Operator request 2026-06-01. Diverges from Flutter's player_bar
which still ships the kebab; parity-map row updated to reflect the
deliberate divergence. ShellScaffold's navController parameter is
kept on the signature for future shell-level navigation needs.
2026-06-01 08:07:48 -04:00
bvandeusen ef8374753a feat(android): denser Most Played tiles - horizontal row matching Flutter
android / Build + lint + test (push) Successful in 4m0s
android / Build signed release APK (push) Has been skipped
Operator device check 2026-06-01: the multi-row grid landed, but the
square 140dp tiles (web-matching) waste vertical space at the per-
tile level. Flutter's CompactTrackCard
(flutter_client/lib/library/widgets/compact_track_card.dart) uses a
horizontal-row pattern - 176dp wide, 56dp tall, 48dp cover thumb +
title/artist column to the right. Much denser; fits ~3x more tiles
in the same screen area.

CompactTrackTile rewritten to that shape:
- Row layout instead of Column
- 48dp square cover (down from 140dp)
- Title + artist column to the right, vertically centered
- 176dp x 56dp outer

MOST_PLAYED_GRID_HEIGHT_DP cut from 600 to 200dp (3 * 56 + 2 * 8
spacing). Skeleton matches the new dimensions.

Diverges from web (which uses square tiles same as Android's prior
shape) - intentional, operator preference. TrackActionsButton from
Flutter's card is omitted for now (would need nav callbacks threaded
through MostPlayedRow); follow-up if kebab-on-tile is wanted.
2026-06-01 08:00:47 -04:00
bvandeusen 7b619152ab fix(android): periodic position polling for smooth seek bar
android / Build + lint + test (push) Successful in 3m41s
android / Build signed release APK (push) Has been skipped
PlayerController only ran the UI-state copy on Media3 Player.Events
callbacks (track change, pause/play, buffer state) - none of which
fire on normal position advancement during playback. The seek bar
appeared to update every 20-30 seconds, whenever a buffer event
happened to fire, instead of ticking smoothly.

Adds a startPositionPolling() coroutine launched on
Dispatchers.Main.immediate after the controller is connected.
Samples controller.currentPosition + bufferedPosition every 500ms
while isPlaying; skips state updates while paused (positionMs stays
at the last value, which is the correct paused-state behavior).
The poll patches only the position fields via .copy() so stale
event-driven snapshots can't clobber the latest state.

Cadence reference: Flutter via just_audio positionStream runs at
~200ms (audio_handler.dart:75); Spotify / Apple Music sit around
250-500ms in-app. 500ms is the battery-friendly middle ground that
still reads as smooth on a slider. Lock-screen / notification
surfaces are driven by Media3's MediaSession separately and don't
need this poll.
2026-06-01 02:21:52 -04:00
bvandeusen 5b050022f1 feat(android): filled heart for liked state, outlined for unliked
android / Build + lint + test (push) Successful in 3m35s
android / Build signed release APK (push) Has been skipped
Liked tracks now render a filled heart (HeartFilled, defined inline
in LikeButton.kt) instead of just recoloring the outlined heart.
Matches the cross-app convention (Spotify, Apple Music, etc.) where
fill is the affordance change rather than tint alone.

Lucide ships outlined-only by design (jar inspection: heart.kt,
heart_crack.kt, heart_off.kt, etc., none filled). Built the filled
variant locally from Lucide's exact path data so the toggle reads as
a fill swap, not a different shape. No new dep - keeps the project's
Lucide-everywhere icon system consistent.

Operator authorized the divergence 2026-06-01 ("changing to material
for this one symbol... is acceptable"); inline ImageVector route
preserves Lucide visual harmony better than mixing material-icons
just for one glyph.
2026-06-01 02:10:21 -04:00