Playback errors slice + scrubber polish + various polish #74
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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)
15b59a21): newplayback_errorstable (migration 0032) with CHECK-gatedkind+resolutionenums 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).99e1df49):PlayerController.onPlaybackStateChanged(STATE_READY)checksplayer.duration; if<= 0orC.TIME_UNSET, fires aPlaybackErrorEvent(kind="zero_duration")and callsseekToNextMediaItem(orstopon the last item).onPlayerErroralso lifts up to the same event shape askind="load_failed". NewPlaybackErrorRepositoryposts via the offline-first MutationQueue path (PLAYBACK_ERROR_REPORTkind + payload + replayer dispatch).PlaybackErrorReporternow fans events to both the snackbar (debounced) and the server (per-event).de61305f): new/admin/playback-errorspage with Unresolved / Resolved tabs. Per-row: Resolve (modal w/ Fixed/Ignored dropdown), Copy (JSON to clipboard including file_path), Delete file (auto-resolves asdeleted). AdminTabs grows from 5 to 6 entries.Already on dev (now folded into this PR)
b83a6a4b) — back toLucide.LibraryBig; drops the material-icons-extended dep.9b3ec654) —HorizontalPagersynced withPrimaryScrollableTabRow.1fdd785e) — keyed Crossfades onstate::classacross LibraryScreen + AlbumDetail + ArtistDetail + PlaylistDetail so same-kind Success → Success refreshes don't full-fade.516d22fc) — active queue → trim upcoming + append radio (dedup seed if current); empty queue → existing setQueue.5fd1a572) —palette.dominantSwatchinstead of vibrant→muted→dominant fallback chain.d9c7aae2+ revert6a54d074) — 4dp rounded custom track replaces M3's 16dp default; Slider height clamp reverted so surrounding layout stays put.Deferred (called out for follow-ups)
Test plan
/admin/playback-errorsshows the report; Copy yields the expected JSON; Delete file removes the file AND closes the row; Resolve modal lets you stamp Fixed or IgnoredOperator: 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.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.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.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.