Bluetooth slice (4/5). DeviceChip: Spotify-style current-route
indicator with icon + name + chevron, single-line ellipsis on long
names. iconFor() maps Kind to Lucide icons (Smartphone / Headphones
/ Bluetooth / Cast / Speaker).
OutputPickerSheet: Material 3 ModalBottomSheet. Header 'Output',
rows = icon + name + 2-line subtitle + selection state (CircleCheck
accent for selected, Circle outline otherwise). Tap selects +
dismisses. permissionDenied flag controls a footer hint row when
BLUETOOTH_CONNECT was refused.
NowPlayingScreen wiring lands in the final commit.
Bluetooth slice (3/5). HiltViewModel projecting the controller's
routesState Flow plus a sheetVisible MutableStateFlow that owns
the sheet's open/close state. onChipTapped + onSheetDismissed
forward to the controller's discovery toggle so active MediaRouter
discovery only runs while the sheet is visible (battery cost).
Compose UI + NowPlaying wiring land next.
Bluetooth slice (2/5). Hilt singleton over androidx.mediarouter.
Owns the callback lifecycle (passive at process start, upgrades to
active when the picker sheet opens, reverts on close) and exposes
the route state as a StateFlow<RouteSnapshot> the ViewModel
projects.
Routes are sorted current-first then by Kind (Bluetooth, Wired,
BuiltIn, Other) so the active output is always at the top of the
sheet.
ViewModel + Compose UI follow in next commits.
Bluetooth slice (1/5). Adds the androidx.mediarouter 1.7.0 dep,
declares BLUETOOTH_CONNECT (needed on Android 12+ to enumerate
paired BT devices by name), and lays down the OutputRoute domain
model.
OutputRoute decouples the picker UI from MediaRouter.RouteInfo
(framework class, can't be constructed in JVM tests - same
constraint we hit with LikeMediaCallback). The Protocol enum
includes UPNP/CAST/SONOS placeholders so the next slice slots in
without a data-model rename - see
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md
for the deferred work.
Controller + ViewModel + Compose UI land in follow-up commits.
Crash on cold boot: ResumeController.restore is suspend, lands on
Dispatchers.Default after awaitReady() unblocks (drift #562), and
calls PlayerController.setQueue which calls MediaController.setMediaItems
— MediaController enforces application-thread access and throws
IllegalStateException 'method is called from a wrong thread'.
Drift #562 added awaitReady() to fix the race where setQueue
early-returned on null controller and silently dropped the persisted
queue. That fix exposed the next bug down the stack: the threading
violation that was previously masked by the early-return.
setQueue now posts the MediaController calls to the controller's
applicationLooper if we're not already on it. UI callers (already
Main) run inline with no re-dispatch latency. ResumeController's
cold-boot path lands on the right thread.
Discovered on-device 2026-06-03 during like-button verification on
the Pixel 6 Pro emulator — crash log at PlayerController.kt:190.
Line 264 already null-checks playlist.systemVariant in the if
condition. PlaylistRef is a data class with a val backing field,
so the smart cast narrows it to String inside the branch — the
!! on line 265 was a no-op the Kotlin compiler was warning about.
MinstrelPlayerService now injects LikesRepository, attaches the new
LikeMediaCallback, sets an initial unfilled CommandButton via
setMediaButtonPreferences, and launches a service-scoped job that
rebuilds the preferences list when the current track or its
server-side liked state changes.
flatMapLatest on (currentMediaItem x observeIsLiked) means the icon
mirrors cross-device likes (web tap flips the notification heart
within EventsStream propagation) and never leaks Flows across track
transitions. Initial emission on subscription guarantees the icon is
correct on the first frame the controller renders.
onDestroy now cancels the service scope before releasing the session
so the like-state job can't touch a released MediaSession.
Closes the Media3 like-button work spec'd in
docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
On-device verification still pending: phone notification, lock
screen, Pixel Watch, Android Auto, offline replay, cross-device.
The unit tests called Media3's SessionCommand(String, Bundle)
constructor, which checkNotNulls the Bundle. JVM unit tests have
no real Android — Bundle.EMPTY is a static field initialized via
the stub jar to null. isReturnDefaultValues=true escapes the
ExceptionInInitializerError but leaves Bundle.EMPTY as null, so
SessionCommand still NPEs on construction. The real fix is
Robolectric, which is disproportionate infrastructure for one
test file (pulls in JUnit 4 ceremony for a JUnit 5 project + a
heavy dep + first-run SDK download flake risk on this CI).
Verification gate for the like button is operator on-device check
per feedback_definition_of_done. The Task 2 wiring lands next,
then we verify the heart appears on the phone notification, lock
screen, and Pixel Watch end-to-end.
Unit tests touching Android framework statics (Bundle.EMPTY,
android.os.Bundle constructor in MediaItem/SessionCommand
construction) failed with NPE/ExceptionInInitializerError because
JVM unit tests run against android.jar's stub classes whose methods
throw "Method ... not mocked" by default. Enable
isReturnDefaultValues so stub methods return defaults — Bundle.EMPTY
ends up null and is fine because we just thread it through
SessionCommand without inspecting it.
Fixes LikeMediaCallbackTest's 5 failures on run #311. Lightweight —
no Robolectric, no androidTest. The first JVM-side test file in the
project to touch Android framework classes.
detekt: onCustomCommand had 3 returns (unsupported / no-mediaItem /
success), ReturnCount cap is 2. Pull the toggle path into a private
launchToggleForCurrent helper so onCustomCommand is a single
return (if/else picks the result code, one Future wrap) and the
helper has at most 2 returns.
New MediaSession.Callback that grants CMD_TOGGLE_LIKE in onConnect
(Media3 issue #2679 guard) and routes onCustomCommand through
LikesRepository.toggleLike so notification/lock-screen/Pixel-Watch
taps inherit the offline-resilient MutationQueue path.
Unit tests cover the onConnect grant, current-state inversion in
both directions, no-op when there is no current MediaItem, and
rejection of unknown custom actions.
MinstrelPlayerService wiring lands in a follow-up commit.
User report: the round dot didn't read as vertically centered on
the 4dp track even though geometrically it was (M3's SliderLayout
centers the track slot within the thumb's height). A small circle
on a thin horizontal bar is a known perceptual offset — the eye
expects the bar to bisect the circle, but the circle's mass extends
above and below in equal amounts the brain reads as a lift.
Swap the 14dp circle for a 4dp x 18dp vertical pill (CircleShape
on a non-square Box renders as a stadium). Same width as the track,
clearly taller — the bar visibly passes through the pill's
horizontal axis with no ambiguity. Also aligns with M3 expressive's
new vertical-handle slider direction.
Updates the ScrubTrack docstring that still referenced the prior
14dp-on-4dp pairing.
Drift #568/#569 scoped AuthCookieInterceptor to PLACEHOLDER_HOST so
the shared OkHttp client wouldn't leak the session cookie to external
image fetches (Coil → musicbrainz, coverartarchive, Lidarr). The fix
was correct but assumed AuthCookieInterceptor would see the original
placeholder.invalid URL — production NetworkModule had BaseUrlInterceptor
running FIRST, so by the time auth's intercept() ran the host was
already rewritten to the real Minstrel server and the placeholder
check failed on every request.
Symptom on v2026.06.02: fresh install login appears to succeed but
no cookie is captured from Set-Cookie and no cookie is attached to
subsequent requests, so the user stays at the Welcome screen.
AuthCookieInterceptorTest already chains the interceptors in the
correct order, which is why the regression went undetected — only
production was wrong.
Fix: swap to (auth, baseUrl, logging). Auth now sees
placeholder.invalid, attaches/captures the cookie, then BaseUrl
rewrites the host for transport.
Second go-round of the same shape of bug: tracks has file_size +
file_format NOT NULL (0002_core_library.up.sql) and my GC test seed
omitted both. The previous fix only addressed the artists.sort_name
column; the tracks INSERT was missing two more.
Use plausible stub values — the GC sweep only joins on track_id,
none of these columns affect what the test exercises.
The artists table requires sort_name (NOT NULL constraint added by
0009_artist_sort.up.sql). My GC integration test was inserting only
name + relying on a separate SELECT to pull the id back, which both
(a) violated the NOT NULL constraint and (b) was unnecessarily
indirect. RETURNING the id directly is the standard pattern used
everywhere else in the test suite.
Test now matches the real-world insert pattern in api.search +
library scan (sort_name mirrors name when no MBID-driven sort hint
is available). Other GC tests in this file don't touch artists so
they were already fine.
Final drift audit finding (Scribe parent #552). LikesRepository
hardcoded LOCAL_USER_ID = "local" as the cached_likes discriminator
since before the auth slice landed. After auth shipped, the app
has a real per-user session but every device wrote rows under the
same "local" bucket — so sharing an Android device between two
Minstrel accounts left the previous user's likes visible to the
new user.
Changes:
- Inject AuthController + ApplicationScope so the repo can read
the current user UUID and subscribe to user-switch events.
- `currentUserId()` resolves the cached_likes discriminator to
`authController.currentUser.value?.id` with the legacy "local"
fallback (ANONYMOUS_USER_ID, renamed from LOCAL_USER_ID) so
pre-#576 cache rows from existing installs stay queryable until
the first authenticated refreshIds() overwrites them.
- All eight call sites that used the constant now use the helper:
observeLikedArtists/Albums/Tracks, observeIsLiked, likedTrackIds,
toggleLike (optimistic upsert + delete), refreshIds (server
replace).
- init {} subscribes to authController.currentUser; when the
signed-in id changes, the OUTGOING user's rows get
likeDao.clearForUser. Mostly a hygiene fix — the discriminator
already prevents the wrong user from SEEING leaked rows, but
without this they pile up forever as different accounts
sign in/out on the same device.
This closes the final drift audit finding from the 2026-06-02 run.
26 of 26 candidate findings either confirmed-and-shipped (24) or
cancelled-as-duplicate (1) or shipped-with-honest-doc-fix (1).
New `internal/gc` package with a single Worker that runs all five
lifecycle / retention sweeps from the 2026-06-02 drift audit on a
1-hour tick. Each sweep is small, idempotent (re-running on
already-clean rows is a no-op), and logs its affected-row count.
Sweeps (Scribe parent #552):
- **#566** GcCloseStalePlayEvents — play_events rows opened > 24h
ago that never got a play_ended (client crash, network drop).
Synthesizes ended_at from duration_played_ms when known, falls
back to now() so the row stops looking "open" to downstream
filters (ended_at IS NULL).
- **#565** GcClosePlaySessionsWithNoRecentEvents — play_sessions
with last_event_at older than 6h get ended_at = last_event_at
("user moved on"); empty sessions older than 1h get closed
too (stale handshakes from clients that never recorded a play).
The audit caught that the column was added but never populated
by any writer — every session row was "open" forever, breaking
downstream dedup queries that assume closed semantics.
- **#567** GcExpireScrobbleQueueFailedRows — drops scrobble_queue
rows in status='failed' older than 14 days. The worker stops
retrying after maxAttempts so these otherwise accumulate
forever on a persistent ListenBrainz outage / revoked token.
- **#574** GcResetStuckSystemPlaylistRuns — flips
system_playlist_runs.in_flight back to false on rows whose
last_run_at is older than 10 minutes. Catches goroutine-panic
wedges where the generator died between SET in_flight=true and
SET in_flight=false; the duplicate-prevention check refuses to
start a fresh regen while in_flight, so a stuck row would
otherwise deadlock all future regens for that user. Records
"stuck-row auto-reset by gc" in last_error so the operator can
tell auto-reset from a recent real failure.
- **#575** GcDeleteExpiredPasswordResets — deletes expired
password_resets rows. Unused expired rows go after a 1h grace
(gives the operator time to debug an active reset attempt);
used rows are kept 7 days for audit.
Wiring:
- main.go `go gcWorker.Run(ctx)` alongside the other periodic
workers (scrobble, similarity, lidarr).
- tickOnce fires once at start so a freshly-deployed server does
its initial sweep without waiting a full tick, matching the
scrobble worker pattern.
- Errors per sweep are logged but do NOT abort the remaining
ones — a transient pgx error from one query shouldn't prevent
the others from running.
Tests:
- 4 integration tests, one per UPDATE/DELETE sweep, that seed
rows-to-sweep + rows-to-leave-alone and assert the right rows
changed state. Skip unless MINSTREL_TEST_DATABASE_URL is set
(mirrors the api package pattern).
- Empty-tables no-op smoke test.
- Run() cancellation honoured (no spinning goroutine at
test-runner exit).
That's all five remaining server-side lifecycle findings from the
audit. The Android LOCAL_USER_ID hardcode (#576) is a separate
refactor that needs auth-store wiring and stays in the queue.
The docstring claimed "the next library scan reconciles missing
files by removing their tracks rows" — but scanner.go only does
filepath.WalkDir + UpsertTrack; it never enumerates existing rows
to check file_path presence, and it never DELETEs orphan rows. The
audit verified this — repo-wide grep finds no orphan-sweep code.
The lie is load-bearing: lidarrquarantine/service.go:270 leans on
this guarantee, so downstream code thinks the orphan case heals
itself. Fix the comment to state reality (admin re-trigger or
manual cleanup) and reference the open follow-up for adding a real
sweep. The actual reconcile pass is a separate piece of work
(needs scanrun integration + retention semantics + tests) and
stays in the Scribe audit queue.
Two web-side findings from the 2026-06-02 drift audit (#552):
- **#559** /library and /playlists each had a +page.server.ts
file calling redirect(308, ...). The app is configured as
adapter-static + ssr=false (+layout.ts:5), so +page.server.ts
files only run at build time / dev server — NEVER at runtime in
the deployed build. Direct navigation to /library or /playlists
(mobile bookmarks, hand-typed URLs) hit a blank page or 404. We
worked around this earlier today by linking the nav directly to
/library/artists, but bookmarks stayed broken. Converted both
files to +page.ts (universal load) — same redirect logic, runs
client-side in the SPA, which is what actually executes.
- **#554** Radio auto-refresh built its exclude= query parameter
from the ENTIRE queue, growing unbounded each refresh as new
tracks were appended. UUIDs are ~36 chars + comma; with the
common 8KB query-string limit, ~220 tracks is the ceiling. A
multi-hour radio session eventually 414'd; the .catch() ate the
error and the player silently stopped topping up — dead radio
with no user-visible signal. Cap exclude to the most recent 100
ids; the server's RecentlyPlayedHours filter already handles
broader history dedup so the request-side cap only needs to
cover the visible queue's recent tail.
Server-side fix for the drift audit finding (Scribe #578, parent
#552). Mirrored on Android (and Flutter) but the root cause and
the smallest blast-radius fix both live here.
The bug:
- Android MeApi.getProfile() calls GET /api/me and deserializes
into MyProfileWire which has nullable display_name + email.
- Server's handleGetMe was emitting the narrower UserView shape
(id, username, is_admin only).
- Android always saw displayName=null, email=null. The Settings →
Profile screen rendered BLANK form fields for users with stored
values.
- Saving from the blank state submitted empty strings to
PUT /api/me/profile, which interprets empty as "clear to NULL"
(me_profile.go:53-65) — DESTROYING the user's saved profile.
- Flutter (flutter_client/lib/api/endpoints/settings.dart:9-12)
has the identical bug pattern.
The fix:
- handleGetMe now emits profileViewFromUser(user) — the same
shape PUT /api/me/profile already returns (meProfileResp:
id, username, display_name, email, is_admin).
- auth.UserFromContext already returns a full dbq.User row, so no
extra DB lookup needed.
- Web's User TypeScript type is narrower than this response but
doesn't care about the extra fields (TS structural typing).
- LoginResp.User still uses UserView; login response unchanged.
New test asserts the regression directly: a user with stored
display_name + email sees them in /api/me. Old test updated to
decode into meProfileResp and assert the nullable fields are
correctly null for an unset profile.
Android side needs no change — the existing wire shape already
expected display_name + email; this just delivers them.
Two related findings from the 2026-06-02 drift audit (#552):
- **#560 (Android)** PlayerController.setQueue() unconditionally
called controller.play() at the end, with no way for
ResumeController to opt out. Cold-boot resume therefore restored
the persisted queue AND auto-started playback, which surprised
users who had paused mid-track before backgrounding the app.
Add `autoplay: Boolean = true` parameter; ResumeController
passes false. Every existing setQueue call site continues to
autoplay (the default is unchanged).
- **#562 (Android)** ResumeController.restore() ran from
MinstrelApplication.onCreate alongside PlayerController's own
init {} block that asynchronously binds the MediaController to
MinstrelPlayerService. On fast devices with slow IPC the
restore could land before mediaController was non-null;
PlayerController.setQueue early-returns on null mediaController,
so the restored queue was silently dropped — the user would
open the app to an empty player after explicitly using "resume
previous queue". Add `awaitReady()` suspend that completes when
the MediaController binding lands; ResumeController awaits it
before calling setQueue.
The two fixes ship together because the autoplay opt-out only
matters once the await fix guarantees the queue actually reaches
the player.
Two findings from the 2026-06-02 drift audit (Scribe parent #552):
- **#577 (Android)** RequestsViewModel.cancel() called refresh() on
BOTH Synced and Queued outcomes. Synced is fine (re-fetch the
canonical list); Queued is offline by definition — the optimistic
removal at the top of cancel() is already correct, and refresh()
on Queued either (a) gets the not-yet-delivered cancelled row
back from the server and snaps it into the list (confusing), or
(b) fails with a transport error and flips the screen to
UiState.Error so the user thinks the cancel failed even though
it's queued. Gate refresh() on outcome == Synced; the mutation
replayer reconciles when connectivity returns.
- **#570 (Android)** LikesRepository.refreshIds() pulled the
server's likes list and INSERTed it into cached_likes — but
never DELETEd local rows the server no longer surfaces. A
cross-device unlike (user likes on web, then unlikes on web)
left the entry visible on Android's Liked tab indefinitely with
no way to clear short of wiping app data. Add
CachedLikeDao.clearForUser + a @Transaction replaceAllForUser
that atomically wipes-then-inserts the user's set; refreshIds()
uses replaceAllForUser so the local cache is exactly what the
server reports. The LOCAL_USER_ID hardcode is its own drift
(#576) and stays for now — fixing it needs threading
AuthStore.userId through the repo.
Five findings + one cancelled duplicate from the 2026-06-02 drift
audit (Scribe parent task #552):
- **#561 (Android)** PlayerController.playbackErrorEventsChannel was
Channel.CONFLATED. The PlaybackErrorReporter coroutine reads it in
a debounce loop that buffers events to coalesce into "Skipped N
unplayable tracks" — but CONFLATED silently dropped every emission
except the latest each time the reader wasn't actively pulling.
A network blip that failed 5 tracks back-to-back surfaced only the
last failure to the snackbar AND only POSTed one playback_errors
row to the admin inbox. Switch to BUFFERED (default capacity 64,
well above any plausible burst rate). Coalescing path now reaches
N > 1 and the admin inbox sees every failure.
- **#563 (server)** systemPlaylistSources rotation whitelist in
playevents/writer.go had drifted behind the migrations. It listed
only for_you + discover; migrations 0021 + 0028 added 6 more
variants (deep_cuts, rediscover, new_for_you, on_this_day,
first_listens, songs_like_artist) that ship as refreshable system
mixes. Plays from those surfaces never advanced the per-user
rotation, so "unplayed first" ordering staled — the same tracks
kept resurfacing. Add all 6 to the map; comment now points at the
migration's CHECK list as the canonical source so future variants
notice the requirement. #573 was the duplicate auditor hit for
the same drift; cancelled in Scribe.
- **#564 (Android)** Android emitted source = "playlist:<variant>"
for system-mix plays from Home and PlaylistDetail, but the
server's rotation matcher keys on the BARE variant string (web
sends the bare form — PlaylistCard.svelte:83). Misalignment meant
system-mix plays from Android never advanced rotation; switching
from web to Android effectively reset the perceived "unplayed
next" ordering. Fix HomeScreen.kt:291 to send bare variant and
PlaylistDetailScreen.kt's play() to prefer systemVariant over the
playlist:<id> tag when the playlist is a refreshable system mix.
User playlists keep playlist:<id> (intentional — rotation only
applies to system mixes anyway).
- **#568 + #569 (Android)** AuthCookieInterceptor was unconditionally
attaching the Minstrel session cookie to every outgoing request
AND wiping the session on any 401. The shared OkHttpClient is also
used by Coil for external image fetches (artwork.musicbrainz.org,
coverartarchive.org, Lidarr /MediaCover URLs); this leaked the
session cookie to those hosts (privacy posture) AND silently
signed users out of Minstrel if any external image host returned
401. Scope both attach + clear to the placeholder.invalid sentinel
host the same way BaseUrlInterceptor was scoped in aec10ce7. Two
new regression tests cover the external-host pass-through. Existing
tests rewritten to make requests through the placeholder URL so
they exercise the in-scope path explicitly.
All five Scribe tasks updated to in_progress at start, will flip to
done after CI green on this push.
Six findings from the 2026-06-02 multi-system drift audit (Scribe
parent task #552):
- **#553 (web)** Tailwind class fix: web admin playback-errors Delete
confirm button was using `bg-action-danger`, an undefined token —
swap to `bg-action-destructive` to match every other destructive
button. Restored the Oxblood signal that distinguishes Delete from
Cancel.
- **#555 (web)** Type the `source` field on `play_started` in the
EventRequest discriminated union. Server's eventRequest accepts it;
web's TS type was missing the slot, so a "drop extra properties"
refactor could silently strip the source tag and break system-
playlist rotation attribution.
- **#556 + #557 (server)** Coverage rollup whitelist was pinned to
('sidecar','embedded','mbcaa','theaudiodb'); migration 0020 added
'deezer' and 'lastfm' as valid cover_art_source values but those
never got wired in, so albums with art from those providers
silently counted as MISSING in the admin Coverage dashboard. The
rollup test was seeding only the pre-0020 sources, masking the
gap in CI. Extend the query to include deezer + lastfm; seed the
test with one row per valid source (regression-guards future
additions).
- **#558 (web)** Auth gate was blocking /forgot-password and
/reset-password/<token> — both are entered without a session by
definition, so the email-link reset flow was bouncing signed-out
users to /login. Add /forgot-password to the public set and a
/reset-password/ prefix matcher. New tests assert both routes
reach their pages without redirect.
- **#571 (server)** Library scanner was indexing only .mp3/.m4a/.flac
/.ogg while the stream handler (media.go) had been extended to
serve .opus, .aac, and .wav. A user with .opus files in their
library never saw them in artist/album listings because the
scanner skipped indexing — silent data loss. Aligned the scanner
to match the media handler.
Scribe statuses updated to in_progress; flipping to done after the
push since these are mechanical and verified directly against the
cited file:lines.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
aec10ce7 added a third early return (the placeholder-host bail)
on top of the existing unparseable-baseUrl elvis return, tripping
detekt's ReturnCount ceiling of 2 on the dev test workflow.
Refactor: keep the placeholder-host early bail (it preserves the
no-op cost for external URLs — no AuthStore read, no URL parse),
fold the unparseable-baseUrl case into a `?:` that falls back to
the original URL. Result is two returns and identical observable
behavior — placeholder hosts get rewritten when baseUrl parses,
fall through unchanged when it doesn't.
Existing unit tests cover all three paths and continue to assert
the same outputs.
Tapping the system media notification previously landed on
whatever shell route MainActivity last rendered (Home / Library /
Search) because MinstrelPlayerService never configured the
session-activity PendingIntent, so Media3 defaulted to the
launcher activity entry point. Operator request: tap should go
straight to the full player.
MinstrelPlayerService.onCreate now builds a PendingIntent
targeting MainActivity with an EXTRA_OPEN_NOW_PLAYING flag and
passes it to MediaSession.Builder.setSessionActivity. The flag
also covers the lock-screen card and the Pixel Watch tile —
both use the same session-activity PendingIntent.
MainActivity reads the extra in onCreate AND onNewIntent (so a
warm app gets the navigation too, not just cold launches), flips
a pendingOpenNowPlaying StateFlow, then strips the extra so a
config-change recreation doesn't re-trigger. The App composable
observes the flag and runs a LaunchedEffect to navigate once the
NavHost is mounted — handles both cold start (BootSplash →
resolved → navigate) and warm start. launchSingleTop avoids
stacking copies if NowPlaying is already on top, and the
onOpenedNowPlaying callback clears the flag post-navigation so
later recompositions don't re-fire.
Divergence from Flutter (intentional): audio_service's default
notification tap behavior just opens the launcher activity at
whatever screen it was on — exactly the behavior the operator
asked to improve.
Lucide has no music-library glyph — Library / LibraryBig /
SquareLibrary all read as a generic books-on-shelf icon without
a label. Operator picked Material's LibraryMusic (the canonical
"books + music note" symbol used by every major music app) as
the recognizable alternative.
Use the Outlined variant: filled icons would clash with the
neighbouring stroked Lucide icons (House, Search, EllipsisVertical),
but Outlined's stroke style matches Lucide closely enough that
the mix is subtle.
Adds the compose-material-icons-extended dependency (version
pinned by compose-bom). R8 strips unused icons in release builds
so the APK cost is just the ones we actually reference.
LibraryBig (stacked book spines) didn't read as "Library" without
the label — easy to misread as a generic stack/columns icon.
SquareLibrary frames the same books-on-shelf glyph inside a
rounded square, matching the visual weight of the neighbouring
House and Search icons better and reading more clearly as a
distinct tappable destination.
Untouched: the RequestsScreen per-row "album"-kind avatar still
uses LibraryBig (parity with Flutter's lib/requests/requests_screen.dart).
Discover suggestion artist images failed to load on Android while
loading fine in the web client. Root cause: BaseUrlInterceptor
unconditionally rewrote every outgoing request's scheme/host/port
to AuthStore.baseUrl. That's correct for Minstrel-bound requests
built with the http://placeholder.invalid sentinel (Retrofit's
frozen baseUrl, every cover-URL builder, ServerImage's
resolveServerUrl). But Lidarr surfaces artist artwork as absolute
URLs to external hosts (artwork.musicbrainz.org,
coverartarchive.org); rewriting those to the Minstrel host
produced 404s that Coil silently fell back from to the User icon.
Web works because the browser fetches the URL as authored. Coil
on Android shares the OkHttp client (and so the interceptor chain)
with Retrofit, which is why the bug surfaced here only.
Add a PLACEHOLDER_HOST companion constant and short-circuit the
rewrite for non-placeholder hosts. Test coverage:
- placeholder host → rewritten to live baseUrl
- absolute external URL → host/scheme/path preserved
- unparseable baseUrl → falls through (no throw)
AuthCookieInterceptor still attaches the Minstrel session cookie
to external requests; external hosts ignore unrecognized cookies
so that's not breaking anything, but it's worth a follow-up DRY
pass to scope auth attachment the same way.
Operator feedback: landscape just stretches the phone-portrait
Compose layout awkwardly — every screen was sized for one column
of cards, so rotation produces wide rows of unrelated content with
big dead bands top and bottom. Until a dedicated tablet/landscape
layout exists, lock the activity to portrait via screenOrientation.
Revisit when a sw600dp resource set + multi-pane layouts land.
The dismissed-latch added a third early return to onPostScroll —
detekt's ReturnCount ceiling is 2 per the project rule. Fold the
NestedScrollSource.UserInput guard into the existing if/else if/else
chain that branches on the drag direction. Behavior is identical;
the source check just becomes the first arm of the expression
rather than an early bail.
74bae74f routed per-artist system mixes through getPlaylist and
always passed the source attribution as the third arg, falling
back to undefined for true user playlists. Vitest's toHaveBeenCalledWith
is arity-strict — playQueue(refs, 0, undefined) is not the same as
playQueue(refs, 0) — so the PlaylistCard contract test failed on
the user-playlist case.
Split the call: pass three args only when a variant tag exists,
two args for user playlists. Preserves source attribution for
songs_like_artist and keeps user playlists source-less as the
test pins.
Operator reproduced black-screen-on-resume by drag-down dismissing
the full player. Root cause: the NestedScrollConnection accumulator
crossed the dismiss threshold, called navController.popBackStack(),
reset accumulated to 0 — but the user's finger was still down and
the pop transition was still running. The next frame's onPostScroll
re-accumulated and re-fired onDismiss(), popping the screen BENEATH
NowPlaying. When that left the back stack empty the NavHost had no
destination to draw, producing a black window until the process
was killed and the activity was cold-launched.
Add a `dismissed` latch that survives until the connection is
disposed (which only happens when NowPlayingScreen leaves the
composition, i.e. the pop completes). After the latch sets we
consume the remaining drag (return `available`) so the underlying
scrollable doesn't paint over-scroll while the pop transitions.
onPreFling also bails after dismissal so the fling can't restart
the accumulator.
The full player previously sat dead-center on wide viewports — cover
+ controls capped at max-w-md left the right two-thirds of the screen
empty while the queue remained behind a drawer toggle. Split the
layout at lg+:
- Extract QueueList from QueueDrawer (header + count + scrollable
list body + empty state); the drawer now wraps QueueList for its
slide-in chrome, and the now-playing route embeds it directly.
- now-playing: at lg+ render the player as a left section + a 384px
(xl: 448px) aside with QueueList. Below lg the layout is unchanged
single-column and the drawer toggle in the bottom row still opens
it (lg:hidden on the toggle button keeps it out of the way once
the panel is permanent).
- QueueList owns the close X conditionally — drawer passes onClose
and the bind:closeButtonRef for focus management; embedded panel
omits both since it has no dismiss action.
QueueTrackRow's drag-to-reorder, click-to-jump, and current-row
highlight all carry over for free since they're owned by the row
component.
User feedback: dislikes both the old M3 20dp thumb and the current
10dp slim variant; wants the bare HTML range thumb the web client
shows. Replace SliderDefaults.Thumb with a plain Box(CircleShape +
accent fill, 14dp). Drops the M3 state-layer halo on press and the
implicit elevation/border so the on-screen result matches a
<input type=\"range\" accent-color> rendering. Slider's 48dp hit
slop is intrinsic to the composable, so tapability is unchanged.
PlaylistCard: per-artist system variants (songs_like_artist) all share
one variant tag but exist as one playlist per seed artist; routing
their play handler through systemShuffle(variant) hit the wrong
playlist (or 404). Detect via seed_artist_id != null and fall through
to getPlaylist(playlist.id) for those; still tag the queue with the
variant so source attribution stays correct.
smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors
Android's rememberSmoothPositionMs. player.position only updates
~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly;
$effect resets on each canonical tick / seek / track-change and a
rAF loop extrapolates at playback rate between ticks.
Wired into both PlayerBar.svelte (mini + expanded seek rows) and
now-playing/+page.svelte. Seek input handler still reads the raw
range value (not smoothed.value) so user drags stay authoritative.
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).
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 '#'.
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
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.
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.
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.
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.
#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.
#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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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).
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.
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.
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'.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
CachedTrackEntity.toDomain() was emitting TrackRef.streamUrl="" (the
default). Tracks routed through MetadataProvider (the Home cache
hydration path used by Most Played) inherited the empty string;
player.setQueue() then got a queue of unplayable tracks.
The server's stream_url is deterministic per track id
(internal/api/convert.go:75 streamURL builder), so the cache mapper
can reconstruct it from the row's id without storing it in Room.
TrackWire.toDomain() continues to use the wire-provided value
(identical content).
Operator reported "tap to play wiring in Most Played queues content
that can't play" on 2026-06-01. The Most Played multi-row layout
itself isn't the cause - the same gap affected the single-row
layout - but the denser display made the dead taps more obvious.