v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
29 commits across four feature slices + a small bug-fix batch.
Slice 1 — Media3 like / heart button on the media session
Heart appears in the phone notification card, lock-screen card, Pixel Watch system media controls, and Android Auto. Toggle routes through the existing
LikesRepository.toggleLikeso it inherits the offline-resilient MutationQueue.LikeMediaCallbackgrantsCMD_TOGGLE_LIKEinonConnect(Media3 issue #2679 guard) and routesonCustomCommandthrough the repositoryMinstrelPlayerServiceinjectsLikesRepository, attaches the callback, sets initial preferences, and launches acurrentMediaItem × observeIsLikedlike-state job so the icon mirrors cross-device likes (web tap flips the phone notification heart automatically)PlayerController.setQueuethread-dispatches to the MediaController'sapplicationLooper— cold-boot resume was crashing withIllegalStateException: method is called from a wrong threadOn-device verified: notification heart works end-to-end.
Slice 2 — Bluetooth output picker
Symfonium-style device chip above the scrubber + bottom sheet showing all available routes (built-in / wired / Bluetooth).
OutputPickerController(Hilt singleton, MediaRouter facade) exposesroutesState: StateFlow<RouteSnapshot>with passive-by-default discovery, upgrades to active when the sheet opensOutputPickerViewModelprojects routes + owns sheet lifecycle, fans discovery toggle to the controllerDeviceChip+OutputPickerSheet(Material 3 ModalBottomSheet) Compose UI with Lucide icons +BLUETOOTH_CONNECTpermission flowOutputRoute.Protocolenum (SYSTEM/UPNP/CAST/SONOS) baked in as forward-compat hook — UPnP slot in without data-model changesSlice 3 — UPnP / DLNA / Sonos basic playback
Discovery + playback to any UPnP-compliant MediaRenderer (Sonos basic, Yamaha MusicCast, Bose SoundTouch, DLNA renderers). Audio flows server → speaker directly; phone is the controller.
Server (~7 files):
SignStreamToken(secret, trackID, exp)+VerifyStreamToken. Stream handler accepts either session cookie OR?token=&exp=query params (constant-time compare viahmac.Equal)POST /api/cast/stream-tokenendpoint — clampedexpSeconds, returns{token, exp, url}for the client to hand off to AVTransportMINSTREL_STREAM_SECRETenv var with file-fallback at<DataDir>/stream_secret(auto-generated on first boot, mode 0600, logged once)auth.OptionalUsermiddleware — attaches user on cookie but doesn't 401 on absenceClient (~13 files):
SsdpDiscovery— UDP multicast listener on239.255.255.250:1900, passiveNOTIFYlisten + explicitM-SEARCHwhen sheet opens,MulticastLocklifecycleDeviceDescription— pull-parser over the UPnP device XML, filters non-renderers, resolves relative control URLsSoapClient— minimal SOAP 1.1 envelope builder + POST via the shared OkHttp; throwsSoapFaultExceptionwith the UPnPerrorCodeAVTransportClient— v1 surface:SetAVTransportURI+Play+StopUpnpDiscoveryController— Hilt singleton composing SSDP + DeviceDescription + AVTransport, exposesFlow<List<UpnpRoute>>OutputPickerControllernowcombines system + UPnP routes;select()branches byprotocol: SYSTEM → MediaRouter, UPNP → mint token + SOAPSetAVTransportURI + PlayOutputPickerSheetgains a multicast-blocked footer hintPending operator on-device verification with a real Sonos speaker before this PR's UPnP path is considered field-tested. Server pieces fully CI-verified; client SOAP / DeviceDescription tests passing via the kxml2 dep added to the test classpath.
Slice 4 — System playlist daily rotation + diversity
SQL audit of the 5 discovery-mix queries showed Rediscover, New for you, and First listens have no date param in their
ORDER BY— same content day-over-day until library state shifts.produceXxxfunctions into oneproduceDiscoveryMix(spec)factory + a per-mixdiscoveryMixSpecslice. Adding a new mix is now one struct literal + a SQL query.dailyRotate: trueon all 3 deterministic mixes — applies a daily-deterministic offset rotate of the pool BEFORE diversify+truncate so each day's slice rotates while contiguous-block ordering (matters for album-coherent mixes) is preserved. Deep Cuts + On this day stayfalsebecause their SQL already day-keys viamd5(id||$2::text).diversify: trueon all 5 — per-album ≤2 / per-artist ≤3 caps applied everywhere, withtopUpFromRawfallback: when caps strip the pool below 100 (album-heavy libraries), append non-capped tracks from the raw pool until the target is hit. Diversity where possible, full playlist always.Bug-fix batch (Android)
LikesRepository.observeLikedTrackIds(): Flow<Set<String>>— direct DAO query, notrackDaojoin.PlaylistDetailViewModel+AlbumDetailViewModelswitched to it; cross-device likes whose track row isn't cached locally now correctly mark playlist/album rows as likedPlaylistsRepositorywrapscoverPath/coverUrlthroughresolveServerUrlso the playlist header art actually fetches viaBaseUrlInterceptor(was a relative URL Coil couldn't resolve)Test plan
🤖 Generated with Claude Code
Adds the client-facing endpoint that issues a signed stream URL for the current track. Authenticated via the standard session cookie. Returns {token, exp, url} where url is a fully-formed stream URL the client passes verbatim to a UPnP / Sonos device's AVTransport.SetAVTransportURI call. expSeconds clamped to [60, 86400]; default 21600 (6h) - long enough to play through any typical track without re-minting mid-playback. MINSTREL_STREAM_SECRET is loaded from env var with a per-machine fallback persisted at <Storage.DataDir>/stream_secret (auto-generated on first boot via 64 random bytes, base64-url-encoded, 0600). The file-based fallback is operator-machine-scoped runtime state, not a user-facing setting - chosen over a DB column to avoid a migration and keep the secret out of cross-instance restores. Operator can override at any time via the env var; default path requires zero config. Tests cover happy-path token issuance + URL formatting, bad-UUID rejection, unauthenticated rejection, the expSeconds clamp at all boundaries, secret env override, auto-gen + file persistence at 0600, second-boot reuse of the persisted file, and rejection of a malformed env value. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>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>