Commit Graph

330 Commits

Author SHA1 Message Date
bvandeusen a9edc12523 fix(android): release-build Timber tree at WARN+ for operator diagnosis
android / Build + lint + test (push) Successful in 4m2s
Debug builds got DebugTree; release builds had no tree planted at
all, so Timber.w / Timber.e calls were dropped silently in
production. That's how the UPnP select diagnostic-prints went
invisible during on-device testing - the released APK had no Timber
output reaching logcat.

Plant a release-only Tree that emits at WARN and above via
android.util.Log.println with the canonical 'Minstrel' tag (or the
caller-supplied tag when present). Keeps DEBUG / INFO traffic out of
production logcat (the chatty stuff is the part we don't want
flooding the buffer) while letting operator-driven adb logcat
sessions still see real failures.
2026-06-03 15:01:03 -04:00
bvandeusen 96f12d6aac fix(android): UPnP select - loud logs on every code path
android / Build + lint + test (push) Has been cancelled
Two silent early returns in selectUpnp were swallowing the most
likely failure modes:
  - currentTrack null (nothing playing locally → can't cast a track)
  - transportFor() returns null (route disappeared or id mismatch)

On-device verification reported 'tap collapses the sheet but no
audio routes', with logcat empty - one of these was firing without
any signal.

Each early-return now Timber.w's why; the runCatching block adds
Timber.i breadcrumbs at every step (mint token, SetAVTransportURI,
Play, done) so the next failure shows exactly how far we got.
2026-06-03 14:59:57 -04:00
bvandeusen 036da9dea8 fix(android): UPnP - clean Sonos friendlyName for picker display
android / Build + lint + test (push) Successful in 4m28s
Sonos uses the friendlyName format
  'Room - Device Type - RINCON_<UDN>'

The picker was showing it verbatim, so the user saw rows like
  'Living Room - Sonos Play:1 Media Renderer - RINCON_5CAAFD79...'

Now strips on the first ' - ' for Sonos manufacturer matches, so the
chip shows just 'Living Room' / 'Kitchen' / etc. Subtitle (manufacturer
+ model) still renders below per the existing sheet design, so the
device-type info isn't lost.

Generic UPnP devices that append a '(192.168.x.x)' IP suffix get that
stripped too via an end-of-string-anchored regex. Empty / blank
friendlyName still falls back to 'Network speaker'.
2026-06-03 14:54:39 -04:00
bvandeusen 3df5e5cb3c fix(android): playlist like state + playlist cover URL
android / Build + lint + test (push) Successful in 3m48s
Two independent bugs surfaced together:

Bug 1: like button on tracks in playlist/album detail screens didn't
reflect actual liked state. LikesRepository.observeLikedTracks() does
a mapNotNull join against trackDao - a liked track whose row isn't in
the local cache yet (e.g. liked via web/notification, cache not
sync'd) gets DROPPED from the returned list. PlaylistDetailViewModel
+ AlbumDetailViewModel both used that as their like-set source, so
those rows showed as not-liked.

Adds LikesRepository.observeLikedTrackIds(): Flow<Set<String>> that
hits the DAO directly via observeLikedIdsOfType - no trackDao join,
no drops. The two ViewModels switch to it. LikedTab continues to use
observeLikedTracks because it needs the full TrackRef to render.

Bug 2: playlist cover art didn't render on the playlist detail
header. Server's derivePlaylistView returns CoverURL as the relative
path "/api/playlists/<id>/cover". PlaylistsRepository's two domain
mappers (CachedPlaylistEntity.toDomain + PlaylistDetailWire
.toPlaylistRef) stored it verbatim - Coil's AsyncImage can't fetch a
relative URL with no base, so the image silently failed.

Wraps the coverPath/coverUrl through resolveServerUrl so the
placeholder.invalid host triggers BaseUrlInterceptor's live-server
rewrite, same idiom every other cover surface (album / artist / track
/ playlist track rows) already uses.

System-playlist 24h refresh investigation pending - need to know how
you verified (server logs, DB state, or client-visible content)
before I can dig into the right layer.
2026-06-03 13:11:55 -04:00
bvandeusen 448c9f2e74 chore(android): UPnP picker - log selectUpnp failures + drop dead fetchJob
android / Build + lint + test (push) Successful in 3m54s
Code-quality review flagged two non-blockers on commit 03cdff54:

1. selectUpnp's runCatching swallowed SOAP / token-mint failures
   silently - OkHttp's logger doesn't see them since they happen in
   our own deserialize / parse code. Adds Timber.w on the failure
   path so operator's on-device Sonos verification can find the
   cause in logcat instead of staring at "nothing happened".

2. UpnpDiscoveryController's fetchJob field was assigned but never
   read or cancelled. appScope is process-lifetime so the launched
   coroutine dies with the process - no explicit cancellation is
   needed. Drop the field + the now-unused Job import.
2026-06-03 12:56:45 -04:00
bvandeusen 03cdff547d feat(android): UPnP picker integration (UPnP slice 6/6)
android / Build + lint + test (push) Successful in 3m52s
UpnpDiscoveryController - Hilt singleton that owns the SSDP listener,
follows each discovered LOCATION URL to fetch + parse the device
description, projects MediaRenderers into a StateFlow<List<UpnpRoute>>.

OutputPickerController now combines system routes with the UPnP
Flow into a unified RouteSnapshot. select() branches by protocol:
SYSTEM goes through MediaRouter as before; UPNP requests a signed
stream token via POST /api/cast/stream-token then calls
AVTransport.SetAVTransportURI + Play against the discovered device.
Local playback pauses on UPnP selection.

OutputPickerSheet gains a MulticastHintRow shown when no UPnP
devices appear after a 3s grace period - the 'your router may be
blocking multicast' footer hint per the spec.

Closes the UPnP slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
On-device verification pending: pair a Sonos / UPnP speaker, confirm
discovery + selection + playback + the multicast-blocked hint.
2026-06-03 12:52:15 -04:00
bvandeusen f8c93e013d feat(android): UPnP SOAP envelope + AVTransport client (UPnP slice 5/6)
android / Build + lint + test (push) Successful in 3m46s
SoapClient - minimal SOAP 1.1 envelope builder + POST via the shared
app OkHttpClient. Sets the SOAPACTION + Content-Type headers UPnP
expects, parses the action's Response element as a Map<String,
String>, raises SoapFaultException on a <s:Fault> response with the
UPnP errorCode + errorDescription extracted.

AVTransportClient - thin wrapper over SoapClient pinned to the
AVTransport:1 service. Three actions for v1: SetAVTransportURI /
Play / Stop. Pause + Seek deferred until we have hardware in the
loop to verify per-device quirks.

Three MockWebServer-driven unit tests cover the SOAPACTION header
shape, XML escaping of special chars in arg values, and the fault
response -> SoapFaultException path. kxml2 on the test classpath
(Task 4) makes XmlPullParserFactory resolve on the JVM.
2026-06-03 12:43:54 -04:00
bvandeusen 1f02813cc6 fix(android): UPnP - add kxml2 to test classpath so DeviceDescriptionTest runs
android / Build + lint + test (push) Successful in 4m7s
Android's XmlPullParserFactory is a Stub-throwing class in android.jar
on the JVM unit-test classpath; the probe pattern from dc5b8252 was
silently skipping the test suite, which gives false test-coverage
confidence. kxml2 is the same parser implementation Android uses
internally - service-provider lookup picks it up automatically once
on the test classpath.

The probe + Assumptions.assumeTrue skip removed; tests now run
unconditionally.

testImplementation(libs.kxml2) - 2.3.0, MIT-licensed, ~80KB. No
production code change.
2026-06-03 12:02:51 -04:00
bvandeusen dc5b8252bb feat(android): UPnP SSDP discovery + device description (UPnP slice 4/6)
android / Build + lint + test (push) Has been cancelled
SsdpDiscovery - UDP multicast listener on 239.255.255.250:1900.
Passive NOTIFY listen always-on once start() is called; explicit
M-SEARCH M-SEARCH on requestActiveScan() (called when picker sheet
opens). WifiManager.MulticastLock held only while running. Emits
each discovered LOCATION URL on a SharedFlow for downstream
description-fetching.

DeviceDescription - pull-parse the <device> XML returned from a
LOCATION URL, extracting friendlyName / manufacturer / modelName +
AVTransport + RenderingControl service control URLs. Filters out
devices without AVTransport (we can't control them).

Three unit tests cover a Sonos-shaped description, a non-renderer
device that should be dropped, and a minimal description with
missing optional fields.
2026-06-03 12:01:00 -04:00
bvandeusen 5f3905f2c7 feat(android): UPnP picker foundation (UPnP slice 3/6)
android / Build + lint + test (push) Successful in 4m11s
UpnpRoute - narrow domain model for a discovered UPnP / DLNA
renderer. Carries the AVTransport + RenderingControl control URLs
the SOAP client uses.

CastApi - Retrofit interface for the new POST /api/cast/stream-token
endpoint (UPnP slice 2/6). Returns {token, exp, url} for the
selection path.

OutputRoute.fromUpnpRoute - companion factory that tags the route
with Protocol.UPNP. Subtitle is 'Manufacturer Model' or falls back
to 'Network speaker' when description fields are blank.

CHANGE_WIFI_MULTICAST_STATE manifest permission - install-time on
all API levels, no runtime prompt. Required for SSDP multicast
discovery.

Discovery + SOAP + integration land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:51:11 -04:00
bvandeusen d7fe515940 fix(android): output picker CI - passive discovery + LongMethod
android / Build + lint + test (push) Successful in 6m26s
Two failures on the slice's final dev tip:

1. OutputPickerController referenced
   MediaRouter.CALLBACK_FLAG_PASSIVE_DISCOVERY which doesn't exist
   in androidx.mediarouter 1.7.0 - the spec hallucinated it.
   Passive discovery is the default behavior when addCallback is
   called with no flag argument. Use the 2-arg overload for the
   init block and downgradeDiscovery; keep CALLBACK_FLAG_REQUEST_DISCOVERY
   for upgradeDiscovery.

2. NowPlayingBody grew to 82 lines after the Task 5 output-picker
   wiring (state collection + permission launcher + LaunchedEffect
   + conditional Sheet). Extracted the BLUETOOTH_CONNECT permission
   plumbing into rememberBluetoothPermissionState, the Column layout
   into NowPlayingContent, and the scrubber+transport pair (which
   share the smoothed playback position) into PlaybackControlsBlock.
   NowPlayingBody is back to ~34 lines and the new helpers each sit
   well under detekt's 60-line LongMethod cap.
2026-06-03 10:49:32 -04:00
bvandeusen 8258b6c29f feat(android): NowPlaying output-picker integration
android / Build + lint + test (push) Failing after 1m33s
Bluetooth slice (5/5). Wires the OutputPickerViewModel + chip +
sheet into NowPlayingScreen.

- Chip renders between BottomActionsRow and ScrubberRow, hidden
  via shouldShowChip() when the only route is the built-in speaker
  (no useful picker with one option).
- Sheet appears on chip tap; selecting a route or dismissing flips
  the ViewModel state and downgrades MediaRouter discovery.
- BLUETOOTH_CONNECT permission requested via the modern
  ActivityResultContracts.RequestPermission() pattern on first
  sheet open. permissionDenied flag passed through to the sheet so
  the 'pair in Settings' hint renders when refused.

Closes the Bluetooth slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md.
On-device verification still pending: pair a Bluetooth speaker,
confirm chip + sheet + select + audio routes; verify wired plug
auto-update + permission-denial hint + long-name truncation.
2026-06-03 10:42:30 -04:00
bvandeusen d10113db54 fix(android): output picker - add Settings icon to permission hint
android / Build + lint + test (push) Failing after 3m13s
Spec's edge-case table calls for the BLUETOOTH_CONNECT permission
hint footer to render alongside a Lucide.Settings icon. Task 4
landed the hint text but not the icon. One-line spec fix on top
of commit a319e3f6.
2026-06-03 10:39:05 -04:00
bvandeusen a319e3f66d feat(android): output picker Compose UI - chip + sheet
android / Build + lint + test (push) Has been cancelled
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.
2026-06-03 10:35:48 -04:00
bvandeusen 692d9dab60 feat(android): OutputPickerViewModel - sheet lifecycle + selection
android / Build + lint + test (push) Has been cancelled
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.
2026-06-03 10:33:06 -04:00
bvandeusen 087486d253 feat(android): OutputPickerController - MediaRouter facade
android / Build + lint + test (push) Failing after 2m39s
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.
2026-06-03 10:30:23 -04:00
bvandeusen 0662c9d5cc feat(android): output picker foundation - mediarouter + OutputRoute
android / Build + lint + test (push) Has been cancelled
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.
2026-06-03 10:27:02 -04:00
bvandeusen e69a5204db fix(android): PlayerController.setQueue dispatches to controller thread
android / Build + lint + test (push) Successful in 4m15s
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.
2026-06-03 09:48:38 -04:00
bvandeusen 4be7e47584 chore(android): drop redundant !! on PlaylistRef.systemVariant
android / Build + lint + test (push) Successful in 3m49s
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.
2026-06-03 09:38:24 -04:00
bvandeusen 551bbf83c2 feat(android): wire MediaSession like button + reactive state
android / Build + lint + test (push) Successful in 3m54s
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.
2026-06-03 09:28:37 -04:00
bvandeusen 43754d03c4 revert(android): drop LikeMediaCallback JVM tests + testOptions flag
android / Build + lint + test (push) Successful in 5m26s
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.
2026-06-03 09:19:37 -04:00
bvandeusen 7807e31b22 fix(android): testOptions isReturnDefaultValues = true
android / Build + lint + test (push) Failing after 2m55s
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.
2026-06-03 09:11:59 -04:00
bvandeusen 9a7cfac7f8 fix(android): LikeMediaCallback ReturnCount — extract toggle helper
android / Build + lint + test (push) Failing after 2m51s
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.
2026-06-03 07:40:33 -04:00
bvandeusen d37ef56bb1 feat(android): LikeMediaCallback for media-session like button
android / Build + lint + test (push) Failing after 1m29s
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.
2026-06-02 23:48:25 -04:00
bvandeusen ad7e57fe66 feat(android): scrubber thumb pill — fixes off-center perception
android / Build + lint + test (push) Successful in 4m0s
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.
2026-06-02 22:48:40 -04:00
bvandeusen 94b3b87785 fix(android): interceptor order — auth before baseUrl
android / Build + lint + test (push) Successful in 4m24s
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.
2026-06-02 21:47:53 -04:00
bvandeusen dbcadf0f93 fix(android): drift #576 — LikesRepository uses real userId, clears on switch
android / Build + lint + test (push) Successful in 4m1s
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).
2026-06-02 18:35:54 -04:00
bvandeusen 5014f7548e fix: drift audit batch 3b — cold-boot resume correctness
android / Build + lint + test (push) Successful in 3m49s
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.
2026-06-02 18:21:39 -04:00
bvandeusen b19c621743 fix: drift audit batch 3a — Android offline correctness
android / Build + lint + test (push) Has been cancelled
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.
2026-06-02 18:18:59 -04:00
bvandeusen fb3116d640 fix: drift audit batch 2 — patterned fixes mirroring prior work
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Has been cancelled
test-go / integration (push) Has been cancelled
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.
2026-06-02 18:16:29 -04:00
bvandeusen 9f0af9c24b feat(android): CI-injected versionName with commit-count iteration
android / Build + lint + test (push) Successful in 4m12s
The APK was shipping with a hardcoded versionName="0.1.0-native"
and versionCode=1 — so the About card never reflected the actual
release, and two same-day re-cuts of the per-day mutable tag
v2026.06.02 looked identical to the update-banner comparator.
Operator wants the iteration restored on the APK side (the docker
tag stays plain per the earlier intentional change).

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

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

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

The existing isVersionNewer comparator already handles the
4-component shape ("2026.06.02.142" > "2026.06.02.141"), so no
client-side logic changes are needed.
2026-06-02 14:07:57 -04:00
bvandeusen 76e64fd022 fix(android): close PlaybackErrorsApi kdoc — Kotlin nested-comment trap
android / Build + lint + test (push) Successful in 4m55s
The kdoc on PlaybackErrorReportRequest mentioned the existing
/api/plays/* endpoint. Kotlin's lexer treats /* inside a /** ... */
kdoc as a NESTED comment opener, which then swallows the outer
*/ — so the entire PlaybackErrorReportRequest data class
disappeared from the symbol table and the four call sites in
PlaybackErrorsApi.kt / MutationReplayer.kt / PlaybackErrorRepository.kt
all reported "Unresolved reference".

This is the trap recorded in the project's KSP-could-not-be-
resolved memory; mark it again. Fix is mechanical: rewrite the
prose as `/api/plays/...` so no /* sequence appears inside a
block comment.
2026-06-02 11:51:19 -04:00
bvandeusen 337fce83a1 fix(android): satisfy detekt — ReturnCount + LongMethod refactors
android / Build + lint + test (push) Failing after 3m20s
Three rule trips from the playback-errors + scrubber commits:

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

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

NowPlayingScreen.ScrubberRow (63 lines → ~50): extract the custom
track Box block into a ScrubTrack(fraction, accent) composable.
The Slider's `track` lambda becomes a one-line call. Pixel output
is identical.
2026-06-02 11:45:45 -04:00
bvandeusen 99e1df4920 feat(android): detect zero-duration tracks, fail fast, report to server
android / Build + lint + test (push) Failing after 2m7s
Operator hit a track that loaded with zero duration; player just sat
on it. Two things needed: skip the dead track immediately, and tell
the server so the admin inbox can surface the bad file.

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

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

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

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

Web admin inbox + UI is the next commit.
2026-06-02 11:30:35 -04:00
bvandeusen 6a54d074fd revert(android): drop NowPlaying scrubber height clamp
android / Build + lint + test (push) Failing after 1m34s
Operator: the slider height clamp shifted the surrounding layout
above and below — not what they intended to change. Revert the
Modifier.height(20.dp) and remove the SCRUB_SLIDER_HEIGHT_DP
constant; the Slider goes back to its M3-default 48dp interactive
component height so adjacent rows sit where they did before.

The slim 4dp custom track stays — that's what addresses the
"puffy bar" feel — and the thumb still sits on it as a visible
14dp circle, with 17dp empty vertical space above and below.
That's the M3 standard layout the operator wants restored.
2026-06-02 10:28:27 -04:00
bvandeusen d9c7aae268 feat(android): slim NowPlaying scrubber — 4dp track + tight slider
android / Build + lint + test (push) Failing after 1m44s
The M3 Slider default track is 16dp tall and the Slider itself
expands to the 48dp interactive-component minimum, so the 14dp
thumb we'd already shrunk to a flat circle was still sitting in
the middle of a fat horizontal pill with lots of empty space
above and below. Operator framing: "puffy, not a tool."

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

Keeps an Android flavor (slightly thicker than the web's 2-3px
hairline; rounded caps; accent fill) without reading as bulky.
2026-06-02 10:24:31 -04:00
bvandeusen 5fd1a5724a fix(android): always use dominant swatch for NowPlaying background
android / Build + lint + test (push) Has been cancelled
Operator framing: the cover art is the main feature of NowPlaying,
not the background. A vibrant accent on the cover (small bright
logo, sticker, stripe) should pop against the background, not be
matched by it. The previous vibrant → muted → dominant fallback
chain often picked a high-saturation accent that covered only a
sliver of the cover, producing gradients that clashed with the
actual image.

Drop to dominantSwatch only — the majority-by-pixel-count color.
If the palette resolves no dominant swatch (extremely rare;
essentially uniform/empty bitmap) the held color stays on the
previous track's dominant, matching the existing "keep previous
on failure" docstring contract.
2026-06-02 10:24:19 -04:00
bvandeusen 516d22fc73 feat(android): Start Radio preserves current playback, queues after
android / Build + lint + test (push) Failing after 1m24s
Operator: tapping Start Radio while music plays previously
reloaded the current track from position 0 because the radio
seed response includes the seed at index 0 and the handler called
setQueue(tracks, 0) — Flutter's playerActions.startRadio does the
same. They want the current track to keep playing untouched,
upcoming queue cleared, radio results appended after.

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

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

Intentional divergence from Flutter — recorded in the docstring
so future ports notice it.
2026-06-02 10:09:00 -04:00
bvandeusen 1fdd785ee5 fix(android): key UiState Crossfades on state::class to stop refresh flicker
android / Build + lint + test (push) Has been cancelled
Pull-to-refresh produced a strong full-screen fade on Library
(both tabs) and the Album / Artist / Playlist detail screens
because their Crossfades were keyed on the entire state value.
A refresh emits a fresh UiState.Success with a NEW data instance
(same kind, different content) so Crossfade animated old grid →
new grid even though both are the same Success branch — the
visible result was a flash that read as "broken/heavy."

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

Only Loading ↔ Success ↔ Error ↔ Empty transitions animate now —
the intended use of Crossfade. Same-kind state updates flow
through Compose's normal recomposition.
2026-06-02 10:07:10 -04:00
bvandeusen 9b3ec65476 feat(android): swipe to change Library tabs
android / Build + lint + test (push) Has been cancelled
Operator: tabs in the Library view should feel swipeable, not just
tappable. Replace the selectedTab Int state + when-block content
with HorizontalPager whose state drives the PrimaryScrollableTabRow.
Tap routes through animateScrollToPage so swipe + tap share one
source of truth.

Horizontal pager gestures don't conflict with the LazyVerticalGrid
inside each tab (different axes) or with PullToRefreshScaffold's
vertical pull (different axes). HorizontalPager renders only the
current page by default; adjacent tabs remain composed during the
swipe but not eager-mounted at start.
2026-06-02 10:04:38 -04:00
bvandeusen b83a6a4bdb revert(android): top-nav Library icon back to Lucide.LibraryBig
android / Build + lint + test (push) Has been cancelled
Operator polled another user and reversed the earlier swap to
Material's LibraryMusic. Restore Lucide.LibraryBig and drop the
material-icons-extended Gradle dependency we added for the
intermediate icon, keeping the icon set Lucide-only.
2026-06-02 10:03:15 -04:00
bvandeusen 4cd38aa62f fix(android): keep BaseUrlInterceptor.intercept under detekt ReturnCount
android / Build + lint + test (push) Successful in 4m18s
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.
2026-06-02 09:50:05 -04:00
bvandeusen 438e81a117 feat(android): media-notification tap opens NowPlaying
android / Build + lint + test (push) Failing after 1m32s
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.
2026-06-02 09:43:45 -04:00
bvandeusen faf2cac0c9 feat(android): use Material Outlined LibraryMusic for top-nav Library
android / Build + lint + test (push) Failing after 2m34s
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.
2026-06-02 09:40:49 -04:00
bvandeusen 22dc343b39 feat(android): swap top-nav Library icon to Lucide.SquareLibrary
android / Build + lint + test (push) Failing after 1m25s
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).
2026-06-02 09:35:58 -04:00
bvandeusen aec10ce787 fix(android): scope BaseUrlInterceptor to placeholder host only
android / Build + lint + test (push) Failing after 1m41s
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.
2026-06-02 09:16:36 -04:00
bvandeusen b9186937b3 chore(android): lock MainActivity to portrait
android / Build + lint + test (push) Successful in 3m39s
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.
2026-06-02 08:12:58 -04:00
bvandeusen 1004b61159 fix(android): keep onPostScroll under detekt ReturnCount limit
android / Build + lint + test (push) Successful in 3m43s
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.
2026-06-02 08:04:21 -04:00
bvandeusen 6a7d9afdbc fix(android): latch NowPlaying drag-dismiss to prevent back-stack underflow
android / Build + lint + test (push) Failing after 1m24s
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.
2026-06-01 23:29:36 -04:00
bvandeusen b77a7121ca feat(android): restyle NowPlaying scrubber thumb to match web client
android / Build + lint + test (push) Successful in 3m39s
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.
2026-06-01 22:40:35 -04:00