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

13 Commits

Author SHA1 Message Date
bvandeusen 9f0af9c24b feat(android): CI-injected versionName with commit-count iteration
android / Build + lint + test (push) Successful in 4m12s
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.
2026-06-02 14:07:57 -04:00
bvandeusen 76e64fd022 fix(android): close PlaybackErrorsApi kdoc — Kotlin nested-comment trap
android / Build + lint + test (push) Successful in 4m55s
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.
2026-06-02 11:51:19 -04:00
bvandeusen 337fce83a1 fix(android): satisfy detekt — ReturnCount + LongMethod refactors
android / Build + lint + test (push) Failing after 3m20s
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.
2026-06-02 11:45:45 -04:00
bvandeusen de61305fde feat(web): admin playback-errors inbox
test-web / test (push) Successful in 34s
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.
2026-06-02 11:34:46 -04:00
bvandeusen 99e1df4920 feat(android): detect zero-duration tracks, fail fast, report to server
android / Build + lint + test (push) Failing after 2m7s
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.
2026-06-02 11:30:35 -04:00
bvandeusen 15b59a214d feat(server): playback_errors table + admin inbox endpoints
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 11m25s
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.
2026-06-02 11:25:50 -04:00
bvandeusen 6a54d074fd revert(android): drop NowPlaying scrubber height clamp
android / Build + lint + test (push) Failing after 1m34s
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.
2026-06-02 10:28:27 -04:00
bvandeusen d9c7aae268 feat(android): slim NowPlaying scrubber — 4dp track + tight slider
android / Build + lint + test (push) Failing after 1m44s
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.
2026-06-02 10:24:31 -04:00
bvandeusen 5fd1a5724a fix(android): always use dominant swatch for NowPlaying background
android / Build + lint + test (push) Has been cancelled
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.
2026-06-02 10:24:19 -04:00
bvandeusen 516d22fc73 feat(android): Start Radio preserves current playback, queues after
android / Build + lint + test (push) Failing after 1m24s
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.
2026-06-02 10:09:00 -04:00
bvandeusen 1fdd785ee5 fix(android): key UiState Crossfades on state::class to stop refresh flicker
android / Build + lint + test (push) Has been cancelled
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.
2026-06-02 10:07:10 -04:00
bvandeusen 9b3ec65476 feat(android): swipe to change Library tabs
android / Build + lint + test (push) Has been cancelled
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.
2026-06-02 10:04:38 -04:00
bvandeusen b83a6a4bdb revert(android): top-nav Library icon back to Lucide.LibraryBig
android / Build + lint + test (push) Has been cancelled
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.
2026-06-02 10:03:15 -04:00