Playback errors slice + scrubber polish + various polish #74

Merged
bvandeusen merged 13 commits from dev into main 2026-06-02 14:14:11 -04:00
Owner

Summary

End-to-end slice for the operator's "track played with 0 length, player sat stuck" report — fast-skip on Android + new admin inbox surfaced from the web — plus the NowPlaying polish (icon revert, swipe tabs, refresh flicker, Start Radio, dominant swatch, slim scrubber).

Playback errors (server + Android + admin UI)

  • Server (15b59a21): new playback_errors table (migration 0032) with CHECK-gated kind + resolution enums and a partial index on unresolved rows. Endpoints: POST /api/playback-errors (any signed-in user reports), GET /api/admin/playback-errors?resolved= (admin list with joined track/album/artist for one-shot row render), POST /api/admin/playback-errors/{id}/resolve (admin marks resolved with a resolution enum).
  • Android (99e1df49): PlayerController.onPlaybackStateChanged(STATE_READY) checks player.duration; if <= 0 or C.TIME_UNSET, fires a PlaybackErrorEvent(kind="zero_duration") and calls seekToNextMediaItem (or stop on the last item). onPlayerError also lifts up to the same event shape as kind="load_failed". New PlaybackErrorRepository posts via the offline-first MutationQueue path (PLAYBACK_ERROR_REPORT kind + payload + replayer dispatch). PlaybackErrorReporter now fans events to both the snackbar (debounced) and the server (per-event).
  • Web admin (de61305f): new /admin/playback-errors page with Unresolved / Resolved tabs. Per-row: Resolve (modal w/ Fixed/Ignored dropdown), Copy (JSON to clipboard including file_path), Delete file (auto-resolves as deleted). AdminTabs grows from 5 to 6 entries.

Already on dev (now folded into this PR)

  • Library icon revert (b83a6a4b) — back to Lucide.LibraryBig; drops the material-icons-extended dep.
  • Swipe to change Library tabs (9b3ec654) — HorizontalPager synced with PrimaryScrollableTabRow.
  • Refresh flicker fix (1fdd785e) — keyed Crossfades on state::class across LibraryScreen + AlbumDetail + ArtistDetail + PlaylistDetail so same-kind Success → Success refreshes don't full-fade.
  • Start Radio preserves current playback (516d22fc) — active queue → trim upcoming + append radio (dedup seed if current); empty queue → existing setQueue.
  • Dominant swatch only (5fd1a572) — palette.dominantSwatch instead of vibrant→muted→dominant fallback chain.
  • Slim scrubber track (d9c7aae2 + revert 6a54d074) — 4dp rounded custom track replaces M3's 16dp default; Slider height clamp reverted so surrounding layout stays put.

Deferred (called out for follow-ups)

  • Hide track + Re-request from Lidarr from the playback-errors inbox row. Hide because the existing quarantine flow is per-user-flag (not a library-hide); Re-request because we'd need album MBID added to the row projection. Operator can still hide/request from the album page in the meantime.
  • Flutter / web-client playback-error reporting. The server endpoint is client-agnostic so it's a small follow-up to add to those surfaces.
  • Stall detection (track plays but stops mid-song). Different cause, different fix.

Test plan

  • CI: vitest (AdminTabs renumbered to 6 tabs), Go server tests, Android ktlint + detekt
  • On Android: play a zero-duration track — player advances within ~1 frame, snackbar shows "Couldn't play X — skipping"
  • /admin/playback-errors shows the report; Copy yields the expected JSON; Delete file removes the file AND closes the row; Resolve modal lets you stamp Fixed or Ignored
  • Library tabs swipe; refresh on Library / Album / Artist / Playlist screens no longer flickers; new Library icon is the books-on-shelf glyph; Start Radio from a playing track doesn't restart playback
## Summary End-to-end slice for the operator's "track played with 0 length, player sat stuck" report — fast-skip on Android + new admin inbox surfaced from the web — plus the NowPlaying polish (icon revert, swipe tabs, refresh flicker, Start Radio, dominant swatch, slim scrubber). ### Playback errors (server + Android + admin UI) - **Server** (`15b59a21`): new `playback_errors` table (migration 0032) with CHECK-gated `kind` + `resolution` enums and a partial index on unresolved rows. Endpoints: `POST /api/playback-errors` (any signed-in user reports), `GET /api/admin/playback-errors?resolved=` (admin list with joined track/album/artist for one-shot row render), `POST /api/admin/playback-errors/{id}/resolve` (admin marks resolved with a resolution enum). - **Android** (`99e1df49`): `PlayerController.onPlaybackStateChanged(STATE_READY)` checks `player.duration`; if `<= 0` or `C.TIME_UNSET`, fires a `PlaybackErrorEvent(kind="zero_duration")` and calls `seekToNextMediaItem` (or `stop` on the last item). `onPlayerError` also lifts up to the same event shape as `kind="load_failed"`. New `PlaybackErrorRepository` posts via the offline-first MutationQueue path (`PLAYBACK_ERROR_REPORT` kind + payload + replayer dispatch). `PlaybackErrorReporter` now fans events to both the snackbar (debounced) and the server (per-event). - **Web admin** (`de61305f`): new `/admin/playback-errors` page with Unresolved / Resolved tabs. Per-row: Resolve (modal w/ Fixed/Ignored dropdown), Copy (JSON to clipboard including file_path), Delete file (auto-resolves as `deleted`). AdminTabs grows from 5 to 6 entries. ### Already on dev (now folded into this PR) - **Library icon revert** (`b83a6a4b`) — back to `Lucide.LibraryBig`; drops the material-icons-extended dep. - **Swipe to change Library tabs** (`9b3ec654`) — `HorizontalPager` synced with `PrimaryScrollableTabRow`. - **Refresh flicker fix** (`1fdd785e`) — keyed Crossfades on `state::class` across LibraryScreen + AlbumDetail + ArtistDetail + PlaylistDetail so same-kind Success → Success refreshes don't full-fade. - **Start Radio preserves current playback** (`516d22fc`) — active queue → trim upcoming + append radio (dedup seed if current); empty queue → existing setQueue. - **Dominant swatch only** (`5fd1a572`) — `palette.dominantSwatch` instead of vibrant→muted→dominant fallback chain. - **Slim scrubber track** (`d9c7aae2` + revert `6a54d074`) — 4dp rounded custom track replaces M3's 16dp default; Slider height clamp reverted so surrounding layout stays put. ### Deferred (called out for follow-ups) - Hide track + Re-request from Lidarr from the playback-errors inbox row. Hide because the existing quarantine flow is per-user-flag (not a library-hide); Re-request because we'd need album MBID added to the row projection. Operator can still hide/request from the album page in the meantime. - Flutter / web-client playback-error reporting. The server endpoint is client-agnostic so it's a small follow-up to add to those surfaces. - Stall detection (track plays but stops mid-song). Different cause, different fix. ## Test plan - [ ] CI: vitest (AdminTabs renumbered to 6 tabs), Go server tests, Android ktlint + detekt - [ ] On Android: play a zero-duration track — player advances within ~1 frame, snackbar shows "Couldn't play X — skipping" - [ ] `/admin/playback-errors` shows the report; Copy yields the expected JSON; Delete file removes the file AND closes the row; Resolve modal lets you stamp Fixed or Ignored - [ ] Library tabs swipe; refresh on Library / Album / Artist / Playlist screens no longer flickers; new Library icon is the books-on-shelf glyph; Start Radio from a playing track doesn't restart playback
bvandeusen added 10 commits 2026-06-02 11:35:18 -04:00
revert(android): top-nav Library icon back to Lucide.LibraryBig
android / Build + lint + test (push) Has been cancelled
b83a6a4bdb
Operator polled another user and reversed the earlier swap to
Material's LibraryMusic. Restore Lucide.LibraryBig and drop the
material-icons-extended Gradle dependency we added for the
intermediate icon, keeping the icon set Lucide-only.
feat(android): swipe to change Library tabs
android / Build + lint + test (push) Has been cancelled
9b3ec65476
Operator: tabs in the Library view should feel swipeable, not just
tappable. Replace the selectedTab Int state + when-block content
with HorizontalPager whose state drives the PrimaryScrollableTabRow.
Tap routes through animateScrollToPage so swipe + tap share one
source of truth.

Horizontal pager gestures don't conflict with the LazyVerticalGrid
inside each tab (different axes) or with PullToRefreshScaffold's
vertical pull (different axes). HorizontalPager renders only the
current page by default; adjacent tabs remain composed during the
swipe but not eager-mounted at start.
fix(android): key UiState Crossfades on state::class to stop refresh flicker
android / Build + lint + test (push) Has been cancelled
1fdd785ee5
Pull-to-refresh produced a strong full-screen fade on Library
(both tabs) and the Album / Artist / Playlist detail screens
because their Crossfades were keyed on the entire state value.
A refresh emits a fresh UiState.Success with a NEW data instance
(same kind, different content) so Crossfade animated old grid →
new grid even though both are the same Success branch — the
visible result was a flash that read as "broken/heavy."

HomeScreen already keys on `state::class` (4b9d-ish prior fix);
apply the same pattern to the four screens that still flicker.
Inner content reads the outer `state` directly via `val s = state`
so the branch still has access to the typed value. Row-level diffs
are owned by LazyVerticalGrid / LazyColumn via item keys, so the
visual update is smooth and granular instead of a full fade.

Only Loading ↔ Success ↔ Error ↔ Empty transitions animate now —
the intended use of Crossfade. Same-kind state updates flow
through Compose's normal recomposition.
feat(android): Start Radio preserves current playback, queues after
android / Build + lint + test (push) Failing after 1m24s
516d22fc73
Operator: tapping Start Radio while music plays previously
reloaded the current track from position 0 because the radio
seed response includes the seed at index 0 and the handler called
setQueue(tracks, 0) — Flutter's playerActions.startRadio does the
same. They want the current track to keep playing untouched,
upcoming queue cleared, radio results appended after.

PlayerController.startRadio now branches on mediaItemCount:
  - Empty queue: existing behavior — setQueue from index 0.
  - Active queue: keep currentMediaItem, removeMediaItems from
    currentIdx+1 to end, then addMediaItems with the radio list.
    When the seed is the currently-playing track (the common
    "Start Radio on the song I'm listening to" case), drop the
    seed from the appended list so it doesn't immediately repeat
    after the current track ends.

queueRefs is updated alongside the controller so the cached
TrackRef list stays consistent. Source tag "radio:<id>" is
preserved for the appended items so play_started attribution
stays correct.

Intentional divergence from Flutter — recorded in the docstring
so future ports notice it.
fix(android): always use dominant swatch for NowPlaying background
android / Build + lint + test (push) Has been cancelled
5fd1a5724a
Operator framing: the cover art is the main feature of NowPlaying,
not the background. A vibrant accent on the cover (small bright
logo, sticker, stripe) should pop against the background, not be
matched by it. The previous vibrant → muted → dominant fallback
chain often picked a high-saturation accent that covered only a
sliver of the cover, producing gradients that clashed with the
actual image.

Drop to dominantSwatch only — the majority-by-pixel-count color.
If the palette resolves no dominant swatch (extremely rare;
essentially uniform/empty bitmap) the held color stays on the
previous track's dominant, matching the existing "keep previous
on failure" docstring contract.
feat(android): slim NowPlaying scrubber — 4dp track + tight slider
android / Build + lint + test (push) Failing after 1m44s
d9c7aae268
The M3 Slider default track is 16dp tall and the Slider itself
expands to the 48dp interactive-component minimum, so the 14dp
thumb we'd already shrunk to a flat circle was still sitting in
the middle of a fat horizontal pill with lots of empty space
above and below. Operator framing: "puffy, not a tool."

Two changes:
- Custom 4dp rounded track replaces SliderDefaults.Track. The
  thumb (14dp) now reads as visibly taller than the bar — the
  classic "handle on a string" cue that says "tool, draggable."
  Also drops M3's stop-indicator dot which the web scrubber
  doesn't have.
- Clamp the Slider's vertical footprint to 20dp via Modifier.
  height. 14dp thumb + 3dp clearance each side, vs the default
  ~17dp empty above and below. Touch area stays usable since the
  drag axis is horizontal — pulling left/right anywhere on the
  thin bar feels natural, and Slider's gesture detector still
  responds to a tap anywhere along its row.

Keeps an Android flavor (slightly thicker than the web's 2-3px
hairline; rounded caps; accent fill) without reading as bulky.
revert(android): drop NowPlaying scrubber height clamp
android / Build + lint + test (push) Failing after 1m34s
6a54d074fd
Operator: the slider height clamp shifted the surrounding layout
above and below — not what they intended to change. Revert the
Modifier.height(20.dp) and remove the SCRUB_SLIDER_HEIGHT_DP
constant; the Slider goes back to its M3-default 48dp interactive
component height so adjacent rows sit where they did before.

The slim 4dp custom track stays — that's what addresses the
"puffy bar" feel — and the thumb still sits on it as a visible
14dp circle, with 17dp empty vertical space above and below.
That's the M3 standard layout the operator wants restored.
feat(server): playback_errors table + admin inbox endpoints
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 11m25s
15b59a214d
New client-reported playback-error log. Surfaces zero-duration
tracks (and future load_failed / stalled kinds) into an admin
inbox so the operator can hide / delete / re-request the
offending track.

Schema (migration 0032):
- playback_errors table with CHECK constraints on the kind +
  resolution enums (per the standing rule that new enum values
  need a migration to add)
- Partial index on unresolved rows for fast inbox lookup
- ON DELETE CASCADE from tracks + users so cleanup is automatic

Endpoints:
- POST /api/playback-errors: any signed-in user reports. Body
  validates track existence + kind whitelist; client_id required
  so support can correlate reports from the same device.
- GET /api/admin/playback-errors?resolved=false&offset=&limit=:
  admin list with join to track/album/artist for table render
  without per-row round-trips. Pagination capped at 200/page.
- POST /api/admin/playback-errors/{id}/resolve: admin marks
  resolved with a resolution enum string.

Auto-resolve on Hide/Delete/Re-request from the inbox row is
driven from the web client (two sequential calls) — keeps the
existing track-action endpoints unchanged.
feat(android): detect zero-duration tracks, fail fast, report to server
android / Build + lint + test (push) Failing after 2m7s
99e1df4920
Operator hit a track that loaded with zero duration; player just sat
on it. Two things needed: skip the dead track immediately, and tell
the server so the admin inbox can surface the bad file.

PlayerController:
- Player.Listener.onPlaybackStateChanged(STATE_READY) now checks
  duration. If it's <= 0 or C.TIME_UNSET, fires a PlaybackErrorEvent
  with kind="zero_duration" and calls seekToNextMediaItem (or stop
  if it was the last item). Per-item evaluation guard keeps repeat
  STATE_READY events (post-seek, post-resume) from re-firing.
- onPlayerError now also surfaces a PlaybackErrorEvent with
  kind="load_failed" + the Media3 exception message as detail.
- playbackErrorEvents flow changes from Flow<String> (title only) to
  Flow<PlaybackErrorEvent> (track_id + kind + title + detail) so
  downstream consumers can both surface a snackbar AND POST to the
  admin inbox without duplicating event emission.

PlaybackErrorRepository (new):
- Wraps POST /api/playback-errors with the offline-first MutationQueue
  fallback per the standing rule for server writes.
- Reuses AuthStore.clientId for the client_id field — same UUID-per-
  install identifier the play-events reporter sends, so support can
  correlate playback errors with surrounding plays.

PlaybackErrorReporter:
- Consumes the new richer event shape. Fires the server report per
  event (no debounce — the admin inbox should capture every report,
  not a coalesced summary). Continues to debounce the user-facing
  snackbar in the 2s window so a burst doesn't spam toasts.

MutationQueue / MutationReplayer:
- Adds PLAYBACK_ERROR_REPORT kind + PlaybackErrorReportPayload +
  enqueuePlaybackErrorReport entry point + replayer dispatch case
  hitting the new PlaybackErrorsApi.

Web admin inbox + UI is the next commit.
feat(web): admin playback-errors inbox
test-web / test (push) Successful in 34s
de61305fde
Surfaces client-reported playback failures from /api/admin/playback-errors
in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row
shows track + artist + album, error kind badge, who hit it, when,
optional client-supplied detail, and the absolute file path so the
operator can grep the library mount without leaving the page.

Per-row actions (RowActionsMenu):
- Resolve (primary) — modal with Fixed / Ignored dropdown for the
  "no further action taken" cases.
- Copy — JSON payload to clipboard with track_id / file_path / kind
  / detail / reporter / client_id / occurred_at. Matches the
  operator's "logs with a copy-out function" ask.
- Delete file (danger, modal-confirm) — uses the existing
  /api/admin/quarantine/{track_id}/delete-file endpoint AND
  auto-stamps resolution='deleted' so a single click closes both
  the file and the inbox row.

Deferred to a follow-up: Hide (the existing quarantine flow is
per-user-flag, not a true library-hide), and Re-request via Lidarr
(needs album MBID join — not in the current ListAdminPlaybackErrors
projection).

Also: admin tab list grows from five to six; AdminTabs.test.ts
updated.
bvandeusen added 1 commit 2026-06-02 11:45:46 -04:00
fix(android): satisfy detekt — ReturnCount + LongMethod refactors
android / Build + lint + test (push) Failing after 3m20s
337fce83a1
Three rule trips from the playback-errors + scrubber commits:

PlayerController.startRadio (4 returns → 2): extract the mid-queue
append branch into appendRadioToQueue(). startRadio just does the
guard checks and dispatches; the helper handles the cursor trim +
addMediaItems. Behavior identical.

PlayerController.onPlaybackStateChanged (4 returns → 2): extract
the duration check + zero-duration error emission + skip logic
into handleZeroDurationIfNeeded(). The listener stays compact (one
return for non-READY, one for repeat-evaluation guard); the helper
owns the failure path.

NowPlayingScreen.ScrubberRow (63 lines → ~50): extract the custom
track Box block into a ScrubTrack(fraction, accent) composable.
The Slider's `track` lambda becomes a one-line call. Pixel output
is identical.
bvandeusen added 1 commit 2026-06-02 11:51:21 -04:00
fix(android): close PlaybackErrorsApi kdoc — Kotlin nested-comment trap
android / Build + lint + test (push) Successful in 4m55s
76e64fd022
The kdoc on PlaybackErrorReportRequest mentioned the existing
/api/plays/* endpoint. Kotlin's lexer treats /* inside a /** ... */
kdoc as a NESTED comment opener, which then swallows the outer
*/ — so the entire PlaybackErrorReportRequest data class
disappeared from the symbol table and the four call sites in
PlaybackErrorsApi.kt / MutationReplayer.kt / PlaybackErrorRepository.kt
all reported "Unresolved reference".

This is the trap recorded in the project's KSP-could-not-be-
resolved memory; mark it again. Fix is mechanical: rewrite the
prose as `/api/plays/...` so no /* sequence appears inside a
block comment.
bvandeusen added 1 commit 2026-06-02 14:07:58 -04:00
feat(android): CI-injected versionName with commit-count iteration
android / Build + lint + test (push) Successful in 4m12s
9f0af9c24b
The APK was shipping with a hardcoded versionName="0.1.0-native"
and versionCode=1 — so the About card never reflected the actual
release, and two same-day re-cuts of the per-day mutable tag
v2026.06.02 looked identical to the update-banner comparator.
Operator wants the iteration restored on the APK side (the docker
tag stays plain per the earlier intentional change).

Scheme:
- Per release: versionName = "${tag}.${commit_count}", e.g.
  "2026.06.02.142", where commit_count = `git rev-list --count HEAD`.
  Monotonic across the project lifetime, deterministic, no manual
  counter to maintain.
- versionCode = commit_count. Monotonic, fits in Int forever (we're
  not hitting 2.1B commits).
- Local / debug / dev builds fall back to versionName="dev" /
  versionCode=1 so the About card reads honestly.

build.gradle.kts:
- defaultConfig reads MINSTREL_VERSION_NAME / MINSTREL_VERSION_CODE
  Gradle properties via project.findProperty with the dev fallbacks.

.gitea/workflows/release.yml:
- android-release: checkout with fetch-depth: 0 (the default shallow
  clone would return 1 for `git rev-list --count HEAD`); new
  Compute release version step exports name + code as step outputs;
  assembleRelease passes them via -P; new job-level outputs propagate
  them to the downstream image-release job.
- image-release: Stage bundled APK + version sidecar pulls the
  computed version_name from needs.android-release.outputs and
  writes it into client/minstrel.apk.version, so the server's
  /api/client/version reports the exact string baked into the APK.
  Without this the sidecar would say "v2026.06.02" while the
  installed APK has "2026.06.02.142" — isVersionNewer would call
  the bundled APK older and the update banner would thrash.

The existing isVersionNewer comparator already handles the
4-component shape ("2026.06.02.142" > "2026.06.02.141"), so no
client-side logic changes are needed.
bvandeusen merged commit 7838038047 into main 2026-06-02 14:14:11 -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#74