OutputPickerController now owns selection state for the UPnP leg and
runs the disconnect flow when the user picks a system route while a
renderer is active.
- Inject OkHttpClient + RemotePlayerState so we can build a
RenderingControlClient at selection time and capture the last-known
remote position on disconnect.
- selectUpnp publishes ActiveUpnp(routeId, routeName, avTransport,
rendering) to ActiveUpnpHolder, marks the route id in
selectedUpnpRouteIdInternal, and honors Sonos topology by routing
through coordinatorRouteFor before SOAP.
- selectSystem now does the disconnect: AVTransport.Stop -> clear the
holder -> seek local ExoPlayer to the remembered position -> resume
if the remote was playing.
- routesState combines 4 sources (system, UPnP, Sonos topology,
upnp-selected id). Non-coordinator Sonos members are filtered out
of the visible list. current resolves from the merged list when a
UPnP route is selected; otherwise from the system snapshot.
- sortRoutes drops the current-first rule -- BuiltIn "Phone speaker"
pins to the top, everything else lowercase-alphabetical. Selection
state moves to the radio-button indicator in the picker row.
- RemotePlayerState gets @Singleton + @Inject constructor() so Hilt
can provide the shared instance to both the picker and the
forthcoming MinstrelForwardingPlayer.
Task 7 of UPnP transport-parity slice. Introduces the central
ForwardingPlayer that branches between local ExoPlayer and the active
UPnP renderer:
- MinstrelForwardingPlayer wraps the delegate Player; play/pause/seek
and the next/previous transport calls translate to AVTransport SOAP
when an ActiveUpnp is set, otherwise forward to super. Position +
isPlaying + duration + playbackState reads pull from
RemotePlayerState while remote.
- 1Hz poll loop drives GetPositionInfo + GetTransportInfo, feeding
RemotePlayerState; the rolling-3 failure heuristic fires onDrop on
the looper for the factory to surface as a snackbar.
- StreamTokenProvider extracts the CastApi.create() Retrofit wiring
into a Hilt singleton so the service-side player and the
controller-side picker share one CastApi instance.
- OutputPickerController constructor swaps Retrofit for
StreamTokenProvider + ActiveUpnpHolder (the holder is wired now for
Task 8). selectUpnp now mints via streamTokens.mint(trackId).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DIDL assertion now checks for XML-escaped form (<dc:title>) since
SoapClient.buildEnvelope escapes all arg values. Lifts xmlEscape to a
top-level internal fun in SoapClient.kt, removing the duplicate private
copy from AVTransportClient. Fixes @Suppress rationale (not Compose).
Renames seek test to reflect colon-separated format; adds unknown-state
getTransportInfo test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After DIDL fix, Sonos accepted SetAVTransportURI + Play, but
playerController.pause() threw IllegalStateException 'method is
called from a wrong thread' because selectUpnp runs the whole
UPnP-selection flow on Dispatchers.Default. UI tap handlers were
fine - they're already on Main - but the cross-thread background
call from OutputPickerController.selectUpnp hit the MediaController's
application-thread guard.
Same fix as the cold-boot resume one earlier today (commit e69a5204
wrapped setQueue): pause / play / seekTo / skipToNext / skipToPrevious
now route through runOnControllerThread, which is a no-op when
already on the application looper and Handler.post otherwise.
Logcat from on-device confirmed Sonos plays after this fix lands -
SetAVTransportURI -> 200, Play -> 200, then the IllegalStateException
was the last failure path.
After the X-Forwarded-Proto fix Sonos now gets a clean https:// URL
but returns vendor error 1023 - empty CurrentURIMetaData. Sonos
requires DIDL-Lite metadata with at minimum <res protocolInfo>
carrying the audio MIME type so it can validate the source before
playback. The original spec said 'Sonos accepts empty DIDL; recoverable
if a device rejects' - that was wrong for Sonos.
Server (cast_token.go):
- Look up the track and return mime (from tracks.file_format) +
title in the cast-token response. mimeForFormat covers the common
formats - mp3, flac, m4a/aac, ogg, opus, wav - falling through to
audio/mpeg for unknowns.
- Missing track returns 404 (apierror.NotFound) instead of letting the
caller mint a token for nothing.
Client (CastApi.kt, AVTransportClient.kt, OutputPickerController.kt):
- StreamTokenResponse gains mime + title (defaulted so old contracts
stay parseable).
- AVTransportClient.setAVTransportURIWithMetadata builds minimal Sonos-
acceptable DIDL-Lite around the URL + MIME + title. xml-escaped.
- selectUpnp calls the new overload; Timber.i now logs the MIME so the
next on-device test shows it.
Generic UPnP renderers tolerate the DIDL shape too - no downside to
sending it everywhere.
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.
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.
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'.
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.
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.
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.
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.
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.
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.
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>
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.
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.
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.
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.
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).
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.