Merge pull request 'dev → main: Android UPnP/Sonos transport parity + server stream URL extension' (#79) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 30s
release / Build + push container image (push) Successful in 1m24s
android / Build + lint + test (push) Successful in 4m12s
test-go / integration (push) Successful in 9m16s

This commit was merged in pull request #79.
This commit is contained in:
2026-06-04 08:15:15 -04:00
35 changed files with 2645 additions and 173 deletions
@@ -39,11 +39,18 @@ data class StreamTokenRequest(
/**
* Response body. [url] is a fully-formed stream URL with [token] and
* [exp] already embedded as query params — callers pass it verbatim
* to `AVTransport.SetAVTransportURI`.
* to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits
* the client needs to build DIDL-Lite metadata for that call: Sonos
* rejects empty DIDL with vendor error 1023, so the server hands back
* the track's MIME (from `tracks.file_format`) and title so the
* client can populate `<res protocolInfo>` and `<dc:title>` without
* a follow-up round trip.
*/
@Serializable
data class StreamTokenResponse(
val token: String,
val exp: Long,
val url: String,
val mime: String = "audio/mpeg",
val title: String = "",
)
@@ -69,7 +69,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
@@ -95,11 +95,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades
@@ -259,45 +257,9 @@ class HomeViewModel @Inject constructor(
*/
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
val detail = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
playlistsRepository.systemShuffle(playlist.systemVariant)
} else {
playlistsRepository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException,
) {
poolMessages.trySend("Couldn't load playlist - check your connection")
return@launch
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
"Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}",
)
return@launch
playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it)
}
// Shared with PlaylistDetailViewModel.play - filters out
// unplayable rows (missing trackId or empty streamUrl) so the
// queue can't end up with tracks Media3 silently rejects.
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
return@launch
}
// Drift #564: send the BARE systemVariant string, not
// "playlist:<variant>" — the server's rotation matcher
// (internal/playevents/writer.go systemPlaylistSources)
// keys on the bare variant. Web sends the bare form too
// (web/src/lib/components/PlaylistCard.svelte:83), so this
// brings Android into alignment. Wrong prefix here meant
// system-mix plays from Android Home never advanced the
// rotation.
val source = if (playlist.refreshable) playlist.systemVariant else null
player.setQueue(tracks, initialIndex = 0, source = source)
}.join()
}
@@ -0,0 +1,404 @@
@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API.
package com.fabledsword.minstrel.player
import android.os.Handler
import android.os.SystemClock
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.TransportState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import timber.log.Timber
/**
* Integration point for UPnP transport parity. Wraps the local
* ExoPlayer; every transport method either forwards (local route
* active -- the default) or translates into AVTransport SOAP +
* [RemotePlayerState] updates (UPnP route active).
*
* Created inside [MinstrelPlayerService]; runs on the service's main
* looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is
* active, the MediaSession's reads of [Player.isPlaying] and
* [Player.getCurrentPosition] pull from [RemotePlayerState]; the
* wrapped ExoPlayer stays paused at the position it had when the
* route was selected.
*
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
* factory wraps that callback into a SharedFlow consumed by the
* NowPlaying surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop syncs the local cursor by comparing the
* 1-based Track index from GetPositionInfo.
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null
// Tracks consecutive non-PLAYING poll observations so a single transient
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
// flip the play/pause button. Manual pause still feels instant because it
// bypasses the poll entirely via applyTransportPaused().
@Volatile private var nonPlayingPollStreak = 0
// Wall-clock of the most-recent within-track seek we issued to Sonos.
// pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS
// -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that
// window reports the *old* position. Without the lockout the scrubber
// visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
// pollLoop's select{} races the delay against this channel so the next
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
// Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI
// catches up to Sonos within RTT rather than the full poll cadence.
// CONFLATED so repeated trySend's between polls don't queue up.
private val pollTrigger = Channel<Unit>(Channel.CONFLATED)
private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
pollTrigger.trySend(Unit)
}
}
init {
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
}
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
// addObserver requires it. The observer just trySend's to the channel.
handler.post {
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
}
}
private fun isRemote(): Boolean = holder.active.value != null
/**
* Returns true when selectUpnp has marked a UPnP route as the intended
* target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this
* window we drop transport commands silently -- they would hit Sonos's
* stale state from a prior session and trigger restarts.
*/
private fun isLoadingUpnp(): Boolean =
holder.target.value != null && holder.active.value == null
override fun play() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
if (active == null) {
super.play()
} else {
scope.launch {
runCatching { active.avTransport.play() }
.onSuccess { remoteState.applyTransportPlaying() }
.onFailure { handleSoapFailure(active, it) }
}
}
}
override fun pause() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
if (active == null) {
super.pause()
} else {
scope.launch {
runCatching { active.avTransport.pause() }
.onSuccess { remoteState.applyTransportPaused() }
.onFailure { handleSoapFailure(active, it) }
}
}
}
override fun seekTo(positionMs: Long) {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
if (active == null) {
super.seekTo(positionMs)
} else {
lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
remoteState.applyPositionInfo(
positionMs = positionMs,
durationMs = remoteState.durationMs,
trackUri = remoteState.currentTrackUri,
trackNumber = remoteState.trackNumber,
)
scope.launch {
runCatching { active.avTransport.seek(positionMs) }
.onFailure { handleSoapFailure(active, it) }
}
}
}
/**
* Widget-driven track change (user taps a track in the queue widget).
* Seeks Sonos to the correct queue slot, then seeks within-track if
* [positionMs] is non-zero.
*/
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w(
"ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
mediaItemIndex, positionMs, active?.routeName,
)
if (active == null) {
super.seekTo(mediaItemIndex, positionMs)
return
}
super.seekTo(mediaItemIndex, positionMs)
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}.onFailure { handleSoapFailure(active, it) }
}
}
override fun seekToNextMediaItem() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
if (active == null) {
super.seekToNextMediaItem()
return
}
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Next. PollLoop reconciles cursor via
// Track index if they diverge.
super.seekToNextMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { active.avTransport.next() }
.onFailure { handleSoapFailure(active, it) }
}
}
override fun seekToPreviousMediaItem() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
if (active == null) {
super.seekToPreviousMediaItem()
return
}
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Previous. PollLoop reconciles.
super.seekToPreviousMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { active.avTransport.previous() }
.onFailure { handleSoapFailure(active, it) }
}
}
override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
override fun getDuration(): Long =
if (isRemote()) remoteState.durationMs else super.getDuration()
override fun isPlaying(): Boolean =
if (isRemote()) remoteState.isPlaying else super.isPlaying()
override fun getPlaybackState(): Int =
if (isRemote()) Player.STATE_READY else super.getPlaybackState()
// Mirror remote state so any consumer that gates on playWhenReady --
// notably MediaSessionService's foreground-keepalive checks and our own
// onTaskRemoved -- sees the remote renderer as the source of truth.
// Without this, swiping the app away with Sonos playing would stop the
// service, kill the poll loop, and leave Sonos orphaned.
override fun getPlayWhenReady(): Boolean =
if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()
override fun release() {
pollJob?.cancel()
scope.cancel()
handler.post {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
}
super.release()
}
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
remoteState.applyError(t)
handler.post { onDrop(active.routeName) }
}
private fun onActiveChanged(active: ActiveUpnp?) {
pollJob?.cancel()
nonPlayingPollStreak = 0
if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
// Pause the wrapped ExoPlayer so we are not playing local audio
// simultaneously with the remote renderer. handler.post targets the
// application looper, so this runs on the same thread that processes
// our override calls -- no race with the pause() override branching
// to SOAP (holder.active is already non-null by the time this post
// fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) }
} else {
remoteState.reset()
}
}
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
private suspend fun pollLoop(active: ActiveUpnp) {
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching { pollOnce(active) }
if (outcome.isSuccess) {
remoteState.recordPollSuccess()
} else if (remoteState.recordPollFailure()) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
return
}
// Race the normal cadence against any external wake (activity
// resume). Whichever wins continues to the next pollOnce.
select<Unit> {
onTimeout(POLL_INTERVAL_MS) {}
pollTrigger.onReceive {}
}
}
}
/**
* One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and forward-sync the local cursor to Sonos's Track
* index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
val now = SystemClock.elapsedRealtime()
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
// Inside the seek-ack window, keep the optimistic position we wrote in
// seekTo -- the poll's reported position is stale until Sonos finishes
// processing the Seek SOAP. Other fields still refresh from the poll.
remoteState.applyPositionInfo(
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
trackNumber = info.track,
)
maybeSyncLocalCursor(info.track)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> {
nonPlayingPollStreak = 0
remoteState.applyTransportPlaying()
}
TransportState.PAUSED -> {
nonPlayingPollStreak += 1
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
remoteState.applyTransportPaused()
}
}
TransportState.STOPPED -> {
nonPlayingPollStreak += 1
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
remoteState.applyTransportStopped()
}
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
}
private fun maybeSyncLocalCursor(sonosTrack: Int) {
if (holder.target.value != null) return
if (sonosTrack <= 0) return
val sonosIdx = sonosTrack - 1
handler.post {
val localIdx = delegate.currentMediaItemIndex
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
Timber.w(
"UPnP cursor catch-up: local=%d -> sonos=%d",
localIdx, sonosIdx,
)
delegate.seekTo(sonosIdx, 0L)
}
}
}
private companion object {
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
// Safety upper bound on how long the polling tick will wait for
// Sonos to ack a user transport. The common case clears event-driven
// when Sonos's reported Track matches the wrapped player; this only
// kicks in if SOAP fails or Sonos drops the ack entirely.
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
}
}
@@ -72,7 +72,7 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val player = playerFactory.build()
val player: Player = playerFactory.build()
val callback = LikeMediaCallback(likesRepository, serviceScope)
val session = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent())
@@ -176,7 +176,11 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent)
val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED
// player.isPlaying is overridden on MinstrelForwardingPlayer to return
// remoteState.isPlaying while UPnP is active, so a swipe-away with
// Sonos playing keeps the service (and its UPnP poll loop) alive.
val activelyPlaying = player.isPlaying ||
(player.playWhenReady && player.playbackState != Player.STATE_ENDED)
if (!activelyPlaying) {
stopSelf()
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
@@ -20,8 +21,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -59,7 +62,17 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController,
private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
) {
/**
* UPnP drop events surfaced from [PlayerFactory.dropEvents]. NowPlaying
* collects this into its snackbar host so a transport / poll failure
* during UPnP playback shows "Disconnected from <name>" to the user.
*/
val dropEvents: SharedFlow<String> = playerFactory.dropEvents
private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -130,11 +143,24 @@ class PlayerController @Inject constructor(
// ── Transport (no-op until the controller is connected) ──────────────
fun play() { mediaController?.play() }
fun pause() { mediaController?.pause() }
fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) }
fun skipToNext() { mediaController?.seekToNextMediaItem() }
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
// Each transport call must run on the MediaController's
// applicationLooper; calling from a background coroutine throws
// IllegalStateException (see PlayerController.setQueue's note).
// UI tap handlers are already on Main so the in-place branch hits;
// the background path only fires for cross-thread callers like
// OutputPickerController.selectUpnp (which calls pause() after
// handing playback off to a UPnP renderer).
fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } }
fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } }
fun seekTo(positionMs: Long) {
mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } }
}
fun skipToNext() {
mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } }
}
fun skipToPrevious() {
mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } }
}
/** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */
fun toggleShuffle() {
@@ -299,7 +325,15 @@ class PlayerController @Inject constructor(
* and advance past the dead track. Otherwise no-op.
*/
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
val current = queueRefs.getOrNull(idx) ?: return
// Skip during UPnP playback (active) AND during the activation load
// window (target set, active not yet wired). ExoPlayer is intentionally
// paused throughout both windows so its STATE_READY duration is always
// 0 / TIME_UNSET -- without this guard we rapid-advance through the
// entire local queue (and spam /api/playback-errors).
val current = queueRefs.getOrNull(idx)
val upnpEngaged = activeUpnpHolder.active.value != null ||
activeUpnpHolder.target.value != null
if (current == null || upnpEngaged) return
val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return
@@ -341,6 +375,19 @@ class PlayerController @Inject constructor(
// awaitReady, the Player.Listener is wired too.
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
startPositionPolling(controller)
// Keep isUpnpLoading current between player-event fires: holder state
// changes (setTarget / set(active)) are independent of Media3 events, so
// onEvents alone would lag behind by up to one event cycle. This collector
// runs for the process lifetime alongside the position poller.
scope.launch {
combine(
activeUpnpHolder.target,
activeUpnpHolder.active,
) { target, active -> target != null && active == null }
.collect { isLoading ->
uiStateInternal.value = uiStateInternal.value.copy(isUpnpLoading = isLoading)
}
}
controller.addListener(
object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
@@ -364,6 +411,11 @@ class PlayerController @Inject constructor(
// Reset the per-item evaluation guard so the new
// item's STATE_READY transition gets a fresh check.
lastEvaluatedItemIndex = -1
// The track flipped -- re-anchor the position interpolator
// so the next polling tick treats the new track's
// remoteState.positionMs as fresh rather than carrying the
// old anchor + elapsed forward.
lastSeenRemotePositionMs = -1L
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -381,15 +433,38 @@ class PlayerController @Inject constructor(
?.mediaMetadata
?.extras
?.getString(MINSTREL_SOURCE_KEY)
val isUpnpLoading = activeUpnpHolder.target.value != null &&
activeUpnpHolder.active.value == null
// When UPnP is active, the wrapped ExoPlayer is paused with
// no real audio loaded -- player.duration / isPlaying /
// currentPosition all reflect that. Read from remoteState
// instead so an onEvents fire (e.g. activity resume) doesn't
// clobber the UI with zeros. Duration falls back to the
// wrapped player's value when Sonos hasn't reported one yet
// (pre-first-poll window, or Sonos still buffering) -- the
// wrapped ExoPlayer was prepared with the same MediaItem so
// it knows the real duration before any SOAP poll lands.
val upnpActive = activeUpnpHolder.active.value != null
uiStateInternal.value =
PlayerUiState(
currentTrack = current,
queue = queueRefs,
queueIndex = idx,
isPlaying = player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying,
isBuffering = !upnpActive &&
player.playbackState == Player.STATE_BUFFERING,
positionMs = if (upnpActive) {
remoteState.positionMs
} else {
player.currentPosition
}.coerceAtLeast(0),
durationMs = effectiveDuration(
upnpActive,
remoteState.durationMs,
player.duration,
desiredIdx = idx,
controllerIdx = idx,
),
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
playbackError = player.playerError?.message,
currentSource = source,
@@ -399,6 +474,7 @@ class PlayerController @Inject constructor(
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
else -> RepeatMode.OFF
},
isUpnpLoading = isUpnpLoading,
)
}
},
@@ -423,15 +499,138 @@ class PlayerController @Inject constructor(
scope.launch(Dispatchers.Main.immediate) {
while (isActive) {
delay(POSITION_POLL_INTERVAL_MS)
if (!controller.isPlaying) continue
uiStateInternal.value = uiStateInternal.value.copy(
positionMs = controller.currentPosition.coerceAtLeast(0),
bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0),
)
tickPositionPoll(controller)
}
}
}
/**
* One position-polling tick. Owns track-change detection too: when UPnP
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
* uiState.queueIndex stuck on the old track even after Sonos has
* advanced. So the tick reads Sonos's reported Track as the source of
* truth, rebuilds the index/title fields itself, and force-syncs the
* wrapped player as defense in depth.
*/
private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) {
interpolatedRemotePosition(effectiveIsPlaying)
} else {
controller.currentPosition
}
val desiredIdx = desiredQueueIndex(controller, upnpActive)
val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration(
upnpActive,
remoteState.durationMs,
controller.duration,
desiredIdx = desiredIdx,
controllerIdx = controller.currentMediaItemIndex,
)
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
// Track adjustments are forward-only AND suppressed while a user
// transport press is pending Sonos confirmation. Together those keep
// either direction of user input from being undone by a stale poll.
val trackChanged = !pendingTransport &&
desiredIdx > current.queueIndex &&
desiredIdx in queueRefs.indices
publishTickIfChanged(
current, trackChanged, desiredIdx,
effectiveIsPlaying, newPos, newDur, newBuf,
)
}
/**
* Event-driven primary path: clear pending the moment Sonos's reported
* Track matches the wrapped player's index. Safety fallback: clear on
* deadline so we don't ignore Sonos's actual state forever if SOAP fails.
*/
private fun resolvePendingTransport(controller: MediaController, upnpActive: Boolean) {
if (!upnpActive || remoteState.pendingTransportDeadlineMs <= 0L) return
val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs
if (sonosIdx == controller.currentMediaItemIndex || timedOut) {
remoteState.clearPendingTransport()
}
}
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
private fun publishTickIfChanged(
current: PlayerUiState,
trackChanged: Boolean,
desiredIdx: Int,
effectiveIsPlaying: Boolean,
newPos: Long,
newDur: Long,
newBuf: Long,
) {
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur
if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
// Intentionally do NOT call controller.seekTo here. That would route
// through MinstrelForwardingPlayer's seekTo override and re-issue
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
// start of the same track it's already playing, restarting the song.
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
// delegate.seekTo (which bypasses the override). If it lags briefly,
// the next pollOnce catches up; the uiState above already reflects
// Sonos's truth for the user.
}
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
if (upnpActive) {
(remoteState.trackNumber - 1).coerceAtLeast(0)
} else {
controller.currentMediaItemIndex
}
// ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
// sleep can stall it for many seconds). To keep the scrubber moving
// smoothly we anchor each fresh reading + an elapsed-realtime stamp;
// between updates we display anchor + elapsed when Sonos is playing.
// A real correction lands as soon as the next poll arrives.
@Volatile private var lastSeenRemotePositionMs: Long = -1L
@Volatile private var positionAnchorMs: Long = 0L
@Volatile private var positionAnchorAtRealtimeMs: Long = 0L
private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
val raw = remoteState.positionMs
val now = SystemClock.elapsedRealtime()
if (raw != lastSeenRemotePositionMs) {
lastSeenRemotePositionMs = raw
positionAnchorMs = raw
positionAnchorAtRealtimeMs = now
}
if (!isPlaying) return positionAnchorMs
// Cap how far past the last anchor we extrapolate. After
// MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the
// displayed position at anchor + cap rather than projecting wildly.
// The next successful poll re-anchors and motion resumes.
val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS)
return positionAnchorMs + delta
}
/**
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
* to a suspend function without pulling in `kotlinx-coroutines-guava`
@@ -489,8 +688,40 @@ class PlayerController @Inject constructor(
private fun sourceExtras(source: String): Bundle =
Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) }
/**
* Duration to surface to the UI. Priority: Sonos's reported duration
* (only when UPnP active and non-zero) -> wrapped ExoPlayer's value
* (only valid once it's probed the stream) -> TrackRef.durationSec
* (always known from the server response). The third tier is what
* keeps the scrubber populated when the user taps Sonos before the
* wrapped player has had time to probe its own duration -- without
* it, both top tiers report 0/TIME_UNSET and the field reads empty
* until the first SOAP poll lands.
*/
@Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns
private fun effectiveDuration(
upnpActive: Boolean,
remoteMs: Long,
localMs: Long,
desiredIdx: Int,
controllerIdx: Int,
): Long {
if (upnpActive && remoteMs > 0) return remoteMs
// Tier 2 (wrapped player's probed duration) only valid when the
// wrapped player is on the same track we're trying to show. After a
// Sonos natural advance the polling tick updates desiredIdx from
// Sonos's truth while controllerIdx is briefly stale -- using the
// wrapped player's duration here would surface the old track's
// length under the new track's title.
if (localMs > 0 && controllerIdx == desiredIdx) return localMs
val ref = queueRefs.getOrNull(desiredIdx) ?: return 0
return ref.durationSec.toLong() * MS_PER_SECOND
}
companion object {
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
private const val MS_PER_SECOND = 1_000L
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
}
}
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
@@ -12,14 +13,19 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds the process-singleton ExoPlayer with our shared OkHttp +
* Builds the process-singleton player with our shared OkHttp +
* SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
* `build()` once during onCreate.
*
@@ -31,12 +37,21 @@ import javax.inject.Singleton
* (sizeBytes cap = rollingCap); our policy layer in the worker layers
* the 2-bucket protection on top by feeding `removeSpan` only for
* unprotected tracks.
*
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
* ExoPlayer. When the UPnP route drops (3 consecutive poll failures or
* a SOAP failure), [dropEvents] emits the route name so the NowPlaying
* surface can show a snackbar. The MutableSharedFlow uses DROP_OLDEST
* with capacity=1 so a burst of failures during a single tear-down
* surfaces as one event rather than queueing N.
*/
@Singleton
class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -46,7 +61,27 @@ class PlayerFactory @Inject constructor(
StandaloneDatabaseProvider(context),
)
fun build(): ExoPlayer {
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
// of drop events (rapid SOAP failures during a single tear-down) surfaces
// as one snackbar rather than queueing N.
private val dropEventsInternal = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
fun build(): Player {
val exo = buildExoPlayer()
return MinstrelForwardingPlayer(
delegate = exo,
holder = activeUpnpHolder,
remoteState = remoteState,
onDrop = { name -> emitDrop(name) },
)
}
private fun buildExoPlayer(): ExoPlayer {
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
val cacheDataSource = CacheDataSource.Factory()
.setCache(simpleCache)
@@ -71,4 +106,8 @@ class PlayerFactory @Inject constructor(
.setHandleAudioBecomingNoisy(true)
.build()
}
private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName)
}
}
@@ -28,4 +28,6 @@ data class PlayerUiState(
val currentSource: String? = null,
val shuffleEnabled: Boolean = false,
val repeatMode: RepeatMode = RepeatMode.OFF,
/** True while the UPnP initial-batch load is in progress (target set, active not yet wired). */
val isUpnpLoading: Boolean = false,
)
@@ -0,0 +1,98 @@
package com.fabledsword.minstrel.player
import javax.inject.Inject
import javax.inject.Singleton
/**
* Synthesized state for the UPnP route -- what the ForwardingPlayer
* exposes via Player.getCurrentPosition / isPlaying / etc. when the
* remote leg is active. Not a Player; a container.
*
* Updates flow in from:
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
*
* The poll-failure counter implements the rolling-3 drop heuristic: 3
* consecutive poll failures = remote considered dropped (returns true
* from recordPollFailure for the caller to surface). Success resets it.
*/
@Singleton
class RemotePlayerState @Inject constructor() {
@Volatile var positionMs: Long = 0L; private set
@Volatile var durationMs: Long = 0L; private set
@Volatile var isPlaying: Boolean = false; private set
@Volatile var currentTrackUri: String = ""; private set
@Volatile var lastError: Throwable? = null; private set
@Volatile var trackNumber: Int = 0; private set
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
// give up waiting). When > 0, a user transport action (next/prev/seekTo
// idx) is in flight: ForwardingPlayer has already moved the wrapped
// player's index, but Sonos's reported Track hasn't refreshed via a
// SOAP poll yet. PlayerController.tickPositionPoll skips track
// adjustments while pending is non-zero. Pending clears when:
// (a) [event-driven, primary] a poll lands and Sonos's reported Track
// matches the wrapped player's currentMediaItemIndex; or
// (b) [safety fallback] the deadline expires (covers SOAP-fail cases
// where Sonos never acks).
@Volatile var pendingTransportDeadlineMs: Long = 0L; private set
fun beginPendingTransport(deadlineMs: Long) {
pendingTransportDeadlineMs = deadlineMs
}
fun clearPendingTransport() {
pendingTransportDeadlineMs = 0L
}
@Volatile private var consecutivePollFailures: Int = 0
fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) {
this.positionMs = positionMs
this.durationMs = durationMs
this.currentTrackUri = trackUri
this.trackNumber = trackNumber
}
fun applyTransportPlaying() { isPlaying = true }
fun applyTransportPaused() { isPlaying = false }
fun applyTransportStopped() {
isPlaying = false
positionMs = 0L
}
fun applyError(t: Throwable) {
isPlaying = false
lastError = t
}
/** Returns true when the rolling threshold trips this call. */
fun recordPollFailure(): Boolean {
consecutivePollFailures += 1
return consecutivePollFailures >= DROP_THRESHOLD
}
fun recordPollSuccess() { consecutivePollFailures = 0 }
fun reset() {
positionMs = 0L
durationMs = 0L
isPlaying = false
currentTrackUri = ""
lastError = null
consecutivePollFailures = 0
trackNumber = 0
pendingTransportDeadlineMs = 0L
}
private companion object {
// ~30 seconds of consecutive poll failures before declaring the route
// dropped. Bumped from 3 because screen-off WiFi sleep / brief Doze
// can stall socket I/O for several seconds without the renderer
// actually being unreachable -- a 3-failure drop kicked us back to
// local audio every time the phone went into a pocket.
const val DROP_THRESHOLD = 30
}
}
@@ -0,0 +1,24 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.api.endpoints.StreamTokenResponse
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mints stream tokens for a given track id. Pulled into its own Hilt
* singleton so [MinstrelForwardingPlayer] (service-side) and
* [com.fabledsword.minstrel.player.output.OutputPickerController]
* (controller-side) don't each construct their own [CastApi] from
* Retrofit. The shared Retrofit instance is unchanged.
*/
@Singleton
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
private val api: CastApi = retrofit.create()
suspend fun mint(trackId: String): StreamTokenResponse =
api.streamToken(StreamTokenRequest(trackId = trackId))
}
@@ -0,0 +1,43 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Shared singleton handle to the currently-active UPnP route's transport
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
* from [OutputPickerController] -- the picker writes; the forwarding
* player reads. Null = no UPnP active (local ExoPlayer path).
*/
data class ActiveUpnp(
val routeId: String,
val routeName: String,
val avTransport: AVTransportClient,
val rendering: RenderingControlClient?,
)
@Singleton
class ActiveUpnpHolder @Inject constructor() {
private val internal = MutableStateFlow<ActiveUpnp?>(null)
val active: StateFlow<ActiveUpnp?> = internal.asStateFlow()
/**
* Pending route id during selectUpnp's queue-load window. Set when
* loadQueueOnSonos starts; cleared on completion (success or failure).
* ForwardingPlayer overrides treat (target != null && active == null)
* as "UPnP intended but SOAP not yet wired" -- drop transport commands
* silently rather than send them to a half-loaded queue.
*/
private val targetInternal = MutableStateFlow<String?>(null)
val target: StateFlow<String?> = targetInternal.asStateFlow()
fun set(active: ActiveUpnp?) { internal.value = active }
fun setTarget(routeId: String?) { targetInternal.value = routeId }
}
@@ -1,14 +1,22 @@
@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
package com.fabledsword.minstrel.player.output
import android.content.Context
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -17,8 +25,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,9 +35,9 @@ import javax.inject.Singleton
/**
* Snapshot of the audio output route state. [current] is the live
* route audio is being delivered to. [available] is every route the
* picker knows about MediaRouter system routes merged with
* UPnP/DLNA renderers discovered on the LAN — sorted current-first
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
* picker knows about -- MediaRouter system routes merged with
* UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
* "Phone speaker" pinned first, everything else alphabetical.
*/
data class RouteSnapshot(
val current: OutputRoute,
@@ -48,7 +57,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [CastApi.streamToken], drive the discovered renderer with
* [StreamTokenProvider.mint], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
@@ -65,10 +74,12 @@ class OutputPickerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
retrofit: Retrofit,
private val playerFactory: PlayerFactory,
private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
) {
private val castApi: CastApi = retrofit.create()
private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder()
@@ -82,12 +93,26 @@ class OutputPickerController @Inject constructor(
*/
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
private val selectUpnpMutex = Mutex()
val routesState: StateFlow<RouteSnapshot> = combine(
systemRoutesInternal,
upnpDiscovery.routes,
) { sys, upnp ->
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
upnpDiscovery.sonosTopology,
selectedUpnpRouteIdInternal,
) { sys, upnp, _, upnpSelected ->
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
val visibleUpnp = upnp
.filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs
.map { OutputRoute.fromUpnpRoute(it) }
val merged = sys.available + visibleUpnp
val current = if (upnpSelected != null) {
merged.firstOrNull { it.id == upnpSelected } ?: sys.current
} else {
sys.current
}
RouteSnapshot(current = current, available = sortRoutes(merged))
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
private val callback = object : MediaRouter.Callback() {
@@ -120,6 +145,32 @@ class OutputPickerController @Inject constructor(
// without forcing Bluetooth scans. (There is no
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback)
// When MinstrelForwardingPlayer reports the active UPnP route has
// dropped (3+ consecutive poll failures or a transport SOAP exception),
// clear the UPnP selection state and fall back to local ExoPlayer at
// the last-known remote position. This mirrors selectSystem's disconnect
// path but skips the Stop SOAP since the device is already unreachable.
scope.launch {
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
}
/**
* Called when the active UPnP route drops unexpectedly (poll-failure
* threshold or SOAP exception). Captures the last remote position +
* play state, clears UPnP selection, and resumes local ExoPlayer at
* the same point. Skips the Stop SOAP (device already unreachable).
* The snackbar is handled independently by the NowPlaying surface
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
*/
private fun handleRemoteDrop() {
val capturedPositionMs = remoteState.positionMs
val wasPlayingRemote = remoteState.isPlaying
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
}
/**
@@ -163,48 +214,191 @@ class OutputPickerController @Inject constructor(
}
private fun selectSystem(route: OutputRoute) {
val wasUpnp = selectedUpnpRouteIdInternal.value
if (wasUpnp != null) {
val active = activeUpnpHolder.active.value
val capturedPositionMs = remoteState.positionMs
val wasPlayingRemote = remoteState.isPlaying
scope.launch {
runCatching { active?.avTransport?.stop() }
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
}
}
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
mediaRouter.selectRoute(target)
}
/**
* Drive the UPnP renderer: mint a token for the currently playing
* track, set the renderer's URI, play, then pause local playback so
* audio yields to the speaker. Wrapped in `runCatching` at each
* step — token failure, transport-lookup failure, and SOAP failure
* each abandon the selection cleanly rather than crashing. Failures
* log at warn level via Timber so on-device verification can find
* the cause in logcat (OkHttp's logger doesn't cover our own
* deserialize / SOAP-parse code paths).
* Drive the UPnP renderer using Sonos native queue mode:
* clear the device's queue, load every track from our local queue
* via AddURIToQueue, point the transport at the queue URI, seek to
* the current index, and play. Wrapped in `runCatching` — SOAP
* failure abandons the selection cleanly.
*
* Order of operations is deliberate:
* 1. Pause local so the user doesn't keep hearing local audio.
* 2. Set target early so ForwardingPlayer drops transport taps
* while the 17-second queue load is in progress.
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
* are never routed to a half-loaded Sonos queue.
*/
private suspend fun selectUpnp(route: OutputRoute) {
val trackId = playerController.uiState.value.currentTrack?.id
if (trackId == null) {
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
val uiState = playerController.uiState.value
val currentTrack = uiState.currentTrack
if (currentTrack == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return
return@withLock
}
val transport = upnpDiscovery.transportFor(route.id)
// Honor Sonos topology: pick the coordinator's route when the user
// tapped a group row. Suppression in routesState already keeps the
// visible row at the coordinator's id, so this is identity in the
// common case -- defensive for follow-up flows.
val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id)
?.let { OutputRoute.fromUpnpRoute(it) } ?: route
val transport = upnpDiscovery.transportFor(effectiveRoute.id)
if (transport == null) {
Timber.w(
"UPnP select skipped: no transport for route id=${route.id} " +
"UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
"(route disappeared or id mismatch with discovery list)",
)
return
return@withLock
}
val rendering = renderingClientFor(effectiveRoute.id)
// Pause local before flipping UI state -- user shouldn't keep hearing
// local audio while we queue up Sonos.
playerController.pause()
selectedUpnpRouteIdInternal.value = effectiveRoute.id
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
// silently while target is set but active is null -- the user's premature
// taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching {
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
transport.setAVTransportURI(token.url)
Timber.i("UPnP select: Play")
transport.play()
playerController.pause()
Timber.i("UPnP select: done")
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
// Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set(
ActiveUpnp(
routeId = effectiveRoute.id,
routeName = effectiveRoute.name,
avTransport = transport,
rendering = rendering,
),
)
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${route.id}")
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value
.firstOrNull { it.id == routeId }
?.renderingControlUrl ?: return null
return RenderingControlClient(SoapClient(okHttp), rcUrl)
}
private fun refresh() {
systemRoutesInternal.value = snapshotFromRouter()
}
@@ -214,24 +408,20 @@ class OutputPickerController @Inject constructor(
.filter { it.matchesSelector(selector) }
.map { OutputRoute.fromRouteInfo(it) }
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
return RouteSnapshot(current = current, available = sortRoutes(current, all))
return RouteSnapshot(current = current, available = sortRoutes(all))
}
/**
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
* Other (UPnP renderers fall in Other). Keeps the active output at
* the top + likely-wanted alternatives next + fallback last.
* BuiltIn "Phone speaker" pinned first; everything else
* alphabetical. Selection state is conveyed by the radio button
* indicator in the picker row, not by sort order.
*/
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
val rank: (OutputRoute) -> Int = { route ->
when {
route.id == current.id -> 0
route.kind == OutputRoute.Kind.Bluetooth -> 1
route.kind == OutputRoute.Kind.Wired -> 2
route.kind == OutputRoute.Kind.BuiltIn -> 3
else -> 4
}
}
return all.sortedBy(rank)
private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
return builtIn + rest.sortedBy { it.name.lowercase() }
}
private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
}
}
@@ -4,8 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -69,12 +72,19 @@ fun OutputPickerSheet(
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 8.dp),
)
snapshot.available.forEach { route ->
RouteRow(
route = route,
isSelected = route.id == snapshot.current.id,
onClick = { onRouteSelected(route) },
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = ROUTE_LIST_MAX_HEIGHT_DP.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(items = snapshot.available, key = { it.id }) { route ->
RouteRow(
route = route,
isSelected = route.id == snapshot.current.id,
onClick = { onRouteSelected(route) },
)
}
}
if (permissionDenied) {
PermissionHintRow()
@@ -198,3 +208,4 @@ private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
private const val ROW_ICON_DP = 24
private const val HINT_ICON_DP = 20
private const val ROUTE_LIST_MAX_HEIGHT_DP = 400
@@ -24,6 +24,7 @@ import javax.inject.Inject
@HiltViewModel
class OutputPickerViewModel @Inject constructor(
private val controller: OutputPickerController,
private val activeUpnpHolder: ActiveUpnpHolder,
) : ViewModel() {
val routes: StateFlow<RouteSnapshot> = controller.routesState
@@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor(
initialValue = controller.routesState.value,
)
val activeUpnp: StateFlow<ActiveUpnp?> = activeUpnpHolder.active
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
private val sheetVisibleInternal = MutableStateFlow(false)
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
@@ -3,12 +3,13 @@ package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service. Three calls
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred
* until we have hardware in the loop to verify each device's quirks
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs
* reject Pause without DIDL).
* High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* GetPositionInfo / GetTransportInfo / queue management
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
*/
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions")
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
@@ -26,6 +27,110 @@ class AVTransportClient(
)
}
/**
* Convenience overload that builds DIDL-Lite metadata around [uri]
* + [mime] + [title] and forwards to [setAVTransportURI]. Sonos
* rejects empty DIDL with vendor error 1023; this constructs the
* minimal-but-Sonos-acceptable shape:
* <DIDL-Lite>
* <item id="0" parentID="-1" restricted="1">
* <dc:title>...</dc:title>
* <upnp:class>object.item.audioItem.musicTrack</upnp:class>
* <res protocolInfo="http-get:*:<mime>:*">...</res>
* </item>
* </DIDL-Lite>
* Generic UPnP renderers tolerate this shape too — there's no
* downside to always sending it. Title falls back to "Minstrel"
* when the caller doesn't supply one.
*/
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
}
/**
* Remove all tracks from the renderer's queue. UPnP action name is
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
* leftover queue from prior sessions before loading our local queue.
*/
suspend fun removeAllTracksFromQueue() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "RemoveAllTracksFromQueue",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Append a track to the renderer's queue. Sonos returns the assigned
* track number + new total queue length in the response, but we don't
* read those (we know our intended position). DIDL-Lite metadata is
* required; reuses the same shape as [setAVTransportURIWithMetadata].
*
* [enqueuedURIPosition] is 1-based; 0 means "append to end" per UPnP.
* Our caller passes 1, 2, 3, ... to ensure stable order.
*/
suspend fun addURIToQueue(
uri: String,
mime: String,
title: String,
enqueuedURIPosition: Int = 0,
) {
val safeTitle = title.ifBlank { "Minstrel" }
val didl = buildDidlLite(uri, mime, safeTitle)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "AddURIToQueue",
args = mapOf(
"InstanceID" to "0",
"EnqueuedURI" to uri,
"EnqueuedURIMetaData" to didl,
"DesiredFirstTrackNumberEnqueued" to enqueuedURIPosition.toString(),
"EnqueueAsNext" to "0",
),
)
}
/** Skip to the next track in the renderer's queue. */
suspend fun next() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Next",
args = mapOf("InstanceID" to "0"),
)
}
/** Skip to the previous track in the renderer's queue. */
suspend fun previous() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Previous",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Seek to a specific track in the queue. UPnP Seek unit "TRACK_NR";
* target is the 1-based track index. Separate from the existing
* [seek] which uses unit REL_TIME for position-within-track.
*/
suspend fun seekToTrack(trackNumber: Int) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Seek",
args = mapOf(
"InstanceID" to "0",
"Unit" to "TRACK_NR",
"Target" to trackNumber.toString(),
),
)
}
suspend fun play() {
soap.call(
controlUrl = controlUrl,
@@ -35,6 +140,15 @@ class AVTransportClient(
)
}
suspend fun pause() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Pause",
args = mapOf("InstanceID" to "0"),
)
}
suspend fun stop() {
soap.call(
controlUrl = controlUrl,
@@ -44,7 +158,114 @@ class AVTransportClient(
)
}
suspend fun seek(positionMs: Long) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Seek",
args = mapOf(
"InstanceID" to "0",
"Unit" to "REL_TIME",
"Target" to formatHhMmSs(positionMs),
),
)
}
suspend fun getPositionInfo(): PositionInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetPositionInfo",
args = mapOf("InstanceID" to "0"),
)
return PositionInfo(
track = result["Track"]?.toIntOrNull() ?: 0,
trackUri = result["TrackURI"].orEmpty(),
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
)
}
suspend fun getTransportInfo(): TransportInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetTransportInfo",
args = mapOf("InstanceID" to "0"),
)
val state = when (result["CurrentTransportState"]) {
"PLAYING" -> TransportState.PLAYING
"PAUSED_PLAYBACK" -> TransportState.PAUSED
"STOPPED" -> TransportState.STOPPED
"TRANSITIONING" -> TransportState.TRANSITIONING
else -> TransportState.UNKNOWN
}
return TransportInfo(state)
}
private fun buildDidlLite(uri: String, mime: String, title: String): String {
// Sonos requires (a) the rinconnetworks namespace declared on
// <DIDL-Lite> even if we don't use Rincon elements directly, and
// (b) a <desc id="cdudn"> element identifying the URI as an
// external (non-Sonos-library) source. Without those, Sonos
// discards our metadata content and regenerates its own with
// class=object.item and the URL query string as the title
// (logcat 2026-06-04 confirmed). Match SoCo's pattern.
val safeTitle = title.ifBlank { "Minstrel" }
return buildString {
append("<DIDL-Lite ")
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
append(":*\">").append(xmlEscape(uri)).append("</res>")
append("<desc id=\"cdudn\" ")
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("RINCON_AssociatedZPUDN")
append("</desc>")
append("</item>")
append("</DIDL-Lite>")
}
}
private fun formatHhMmSs(positionMs: Long): String {
val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0)
val h = totalSec / SECONDS_PER_HOUR
val m = (totalSec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
val s = totalSec % SECONDS_PER_MINUTE
return "%d:%02d:%02d".format(h, m, s)
}
private fun parseHhMmSs(raw: String): Long {
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
return if (parts.size == EXPECTED_HMS_PARTS) {
val (h, m, s) = parts
((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND
} else {
0L
}
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
const val MILLIS_PER_SECOND = 1000L
const val SECONDS_PER_MINUTE = 60L
const val SECONDS_PER_HOUR = 3600L
const val EXPECTED_HMS_PARTS = 3
}
}
data class PositionInfo(
val track: Int,
val trackUri: String,
val relTimeMs: Long,
val trackDurationMs: Long,
)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
data class TransportInfo(val state: TransportState)
@@ -22,10 +22,12 @@ data class DeviceDescription(
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
) {
companion object {
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
private const val ZGT_SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
private const val TAG_SERVICE = "service"
private const val TAG_UDN = "UDN"
@@ -43,6 +45,7 @@ data class DeviceDescription(
*/
fun parse(xml: String, base: HttpUrl): DeviceDescription? {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(xml.reader())
}
val acc = ParseState()
@@ -58,6 +61,7 @@ data class DeviceDescription(
modelName = acc.modelName,
avTransportControlUrl = avt,
renderingControlUrl = acc.rcControlUrl,
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
)
}
@@ -91,6 +95,7 @@ data class DeviceDescription(
when (acc.serviceType) {
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
}
acc.inService = false
}
@@ -115,6 +120,7 @@ data class DeviceDescription(
var modelName: String = ""
var avtControlUrl: HttpUrl? = null
var rcControlUrl: HttpUrl? = null
var zgtControlUrl: HttpUrl? = null
var inService: Boolean = false
var serviceType: String = ""
var serviceControlUrl: String = ""
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* RenderingControl service wrapper for hardware-volume routing while a
* UPnP route is active. GetVolume seeds an in-memory cache; SetVolume
* is invoked by NowPlayingScreen's volume-key interceptor. Clamps to
* the UPnP-standard 0..100 range.
*/
class RenderingControlClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun getVolume(channel: String = "Master"): Int {
val args = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetVolume",
args = mapOf("InstanceID" to "0", "Channel" to channel),
)
return args["CurrentVolume"]?.toIntOrNull() ?: 0
}
suspend fun setVolume(volume: Int, channel: String = "Master") {
val clamped = volume.coerceIn(VOLUME_MIN, VOLUME_MAX)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetVolume",
args = mapOf(
"InstanceID" to "0",
"Channel" to channel,
"DesiredVolume" to clamped.toString(),
),
)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
const val VOLUME_MIN = 0
const val VOLUME_MAX = 100
}
}
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player.output.upnp
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
@@ -8,6 +9,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import timber.log.Timber
/**
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
@@ -25,6 +27,7 @@ import org.xmlpull.v1.XmlPullParserFactory
*/
class SoapClient(
private val okHttp: OkHttpClient,
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
) {
suspend fun call(
controlUrl: HttpUrl,
@@ -41,6 +44,7 @@ class SoapClient(
.build()
okHttp.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
onRawResponse?.invoke(action, body)
if (!response.isSuccessful) {
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
}
@@ -69,15 +73,9 @@ class SoapClient(
append("</s:Envelope>")
}
private fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(body.reader())
}
val responseTag = "${action}Response"
@@ -102,7 +100,7 @@ class SoapClient(
} else {
if (inResponse) {
val name = parser.name
val text = runCatching { parser.nextText() }.getOrDefault("")
val text = readElementContent(parser, name)
args[name] = text
}
inResponse
@@ -112,6 +110,59 @@ class SoapClient(
else -> inResponse
}
/**
* Read the content of the currently-started element. Try nextText() first
* (works when the content is text -- escaped XML included). If that throws
* (because the content is nested elements), manually walk to the matching
* END_TAG, accumulating text and re-serializing child elements.
*
* Some Sonos firmware sends the GetZoneGroupState payload as nested XML
* elements without escaping; this fallback recovers that path.
*/
private fun readElementContent(parser: XmlPullParser, tagName: String): String {
return runCatching { parser.nextText() }.getOrElse {
readUntilEndTag(parser, tagName)
}
}
private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String {
val sb = StringBuilder()
var depth = 1
var done = false
while (!done && depth > 0) {
when (parser.next()) {
XmlPullParser.START_TAG -> {
appendStartTag(sb, parser)
depth += 1
}
XmlPullParser.END_TAG -> {
depth -= 1
if (depth == 0 && parser.name == tagName) {
done = true
} else {
sb.append("</").append(parser.name).append('>')
}
}
XmlPullParser.TEXT -> sb.append(parser.text.orEmpty())
XmlPullParser.END_DOCUMENT -> done = true
else -> Unit
}
}
return sb.toString()
}
private fun appendStartTag(sb: StringBuilder, parser: XmlPullParser) {
sb.append('<').append(parser.name)
for (i in 0 until parser.attributeCount) {
sb.append(' ')
.append(parser.getAttributeName(i))
.append("=\"")
.append(parser.getAttributeValue(i))
.append('"')
}
sb.append('>')
}
private fun faultCodeOf(body: String): String =
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
@@ -138,3 +189,42 @@ class SoapClient(
*/
class SoapFaultException(val code: String, val description: String) :
Exception("SOAP fault $code: $description")
private const val MAX_DIAGNOSTIC_RESPONSES = 6 // 3 polls x 2 action types
private const val MAX_BODY_LOG_CHARS = 2048
/**
* Returns a [SoapClient] that logs the raw response body for the first
* [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS]
* (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the
* callback is a no-op so there is no persistent log spam. Logged at WARN
* so release builds capture it without a separate log-level override.
*/
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
val counter = AtomicInteger(0)
return SoapClient(okHttp) { action, body ->
if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient
val n = counter.incrementAndGet()
if (n <= MAX_DIAGNOSTIC_RESPONSES) {
Timber.w(
"UPnP %s response #%d (%s): %s",
label, n, action,
body.take(MAX_BODY_LOG_CHARS),
)
}
}
}
private val DIAGNOSTIC_ACTIONS = setOf(
"GetPositionInfo",
"GetTransportInfo",
"GetZoneGroupState",
)
/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
internal fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
@@ -1,7 +1,10 @@
@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -13,6 +16,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -44,6 +48,9 @@ class UpnpDiscoveryController @Inject constructor(
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
init {
ssdp.start(appScope)
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
@@ -83,13 +90,77 @@ class UpnpDiscoveryController @Inject constructor(
*/
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
return AVTransportClient(
loggingSoapClient(okHttp, route.name),
route.avTransportControlUrl,
)
}
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
routesInternal.value =
routesInternal.value.filterNot { it.id == route.id } + route
upsertRoute(route)
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
refreshSonosTopology(route)
}
}
private fun upsertRoute(route: UpnpRoute) {
val current = routesInternal.value
val idx = current.indexOfFirst { it.id == route.id }
routesInternal.value = if (idx < 0) {
current + route
} else {
current.toMutableList().also { it[idx] = route }
}
}
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run {
Timber.w(
"Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled",
anySonos.id,
)
return
}
val groups = runCatching {
ZoneGroupTopologyClient(
loggingSoapClient(okHttp, "ZGT-${anySonos.id}"),
zgtUrl,
).getZoneGroupState()
}
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
.getOrNull() ?: return
Timber.w(
"Sonos topology refreshed for %s: %d group(s)",
anySonos.id,
groups.size,
)
sonosTopologyInternal.value = groups
}
/**
* Returns the coordinator UDN's full route for the group [udn] belongs
* to, or null if topology hasn't loaded / the udn is in no group. UDN
* normalization strips the "uuid:" prefix because DeviceDescription's
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
*/
fun coordinatorRouteFor(udn: String): UpnpRoute? {
val bare = udn.bareUdn()
val coord = sonosTopologyInternal.value
.firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } }
?.coordinatorUdn ?: return null
return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() }
}
/**
* UDNs of every NON-coordinator Sonos group member. The picker
* controller suppresses these from the visible list.
*/
fun nonCoordinatorMemberUdns(): Set<String> {
return sonosTopologyInternal.value.flatMap { g ->
g.members.map { it.udn.bareUdn() }
.filter { it != g.coordinatorUdn.bareUdn() }
}.toSet()
}
/**
@@ -111,6 +182,7 @@ class UpnpDiscoveryController @Inject constructor(
modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl,
renderingControlUrl = desc.renderingControlUrl,
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
)
}
}
@@ -155,3 +227,20 @@ class UpnpDiscoveryController @Inject constructor(
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
}
}
/**
* Normalize a Sonos UDN to its bare RINCON form for cross-comparison.
*
* Three places use UDN strings with different conventions:
* - DeviceDescription's <UDN> tag carries the `uuid:` prefix.
* - Sonos exposes one UDN per embedded device. The MediaRenderer adds
* a `_MR` suffix and the MediaServer adds `_MS`.
* - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with
* neither the `uuid:` prefix nor any device suffix.
*
* To compare any pair of those, strip the prefix and the suffix so
* everything reduces to the underlying speaker identity.
*/
internal fun String.bareUdn(): String = removePrefix("uuid:")
.removeSuffix("_MR")
.removeSuffix("_MS")
@@ -7,10 +7,10 @@ import okhttp3.HttpUrl
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
* SSDP details so the picker UI consumes a narrow domain shape.
*
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds
* (group join/leave, zone topology) live in a separate Sonos extension
* scoped in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md.
* Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the
* ZoneGroupTopology service in their device description; non-Sonos devices
* leave it null. The discovery controller uses that URL to aggregate stereo
* pairs and multi-speaker groups into single picker rows.
*
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
* raw `<friendlyName>` straight from the device description — callers
@@ -24,4 +24,5 @@ data class UpnpRoute(
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
)
@@ -0,0 +1,90 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* One Sonos zone group as exposed by ZoneGroupTopology.GetZoneGroupState.
* Stereo pairs and multi-speaker groups all appear as one group with
* multiple members; one member is the coordinator we send SOAP to.
*/
data class SonosZoneGroup(
val coordinatorUdn: String,
val name: String,
val members: List<SonosZoneMember>,
)
data class SonosZoneMember(
val udn: String,
val roomName: String,
val location: String?,
val channelMapSet: String?,
)
object SonosTopology {
fun parse(xml: String): List<SonosZoneGroup> {
val effective = if (xml.contains("&lt;ZoneGroup")) {
unescapeXmlEntities(xml)
} else {
xml
}
return runCatching { parseStrict(effective) }.getOrDefault(emptyList())
}
private fun unescapeXmlEntities(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&") // must be last to avoid double-decoding
private fun parseStrict(xml: String): List<SonosZoneGroup> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(xml.reader())
}
val groups = mutableListOf<SonosZoneGroup>()
var currentCoordinator: String? = null
var currentMembers: MutableList<SonosZoneMember>? = null
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.name) {
TAG_ZONE_GROUP -> {
currentCoordinator = parser.getAttributeValue(null, ATTR_COORDINATOR)
currentMembers = mutableListOf()
}
TAG_ZONE_GROUP_MEMBER -> currentMembers?.add(memberOf(parser))
}
XmlPullParser.END_TAG -> if (parser.name == TAG_ZONE_GROUP) {
val members = currentMembers ?: emptyList()
val coord = currentCoordinator
if (coord != null && members.isNotEmpty()) {
val name = members.firstOrNull { it.udn == coord }?.roomName
?: members.first().roomName
groups.add(SonosZoneGroup(coord, name, members))
}
currentCoordinator = null
currentMembers = null
}
}
parser.next()
}
return groups
}
private fun memberOf(parser: XmlPullParser): SonosZoneMember = SonosZoneMember(
udn = parser.getAttributeValue(null, ATTR_UUID).orEmpty(),
roomName = parser.getAttributeValue(null, ATTR_ZONE_NAME).orEmpty(),
location = parser.getAttributeValue(null, ATTR_LOCATION),
channelMapSet = parser.getAttributeValue(null, ATTR_CHANNEL_MAP_SET),
)
private const val TAG_ZONE_GROUP = "ZoneGroup"
private const val TAG_ZONE_GROUP_MEMBER = "ZoneGroupMember"
private const val ATTR_COORDINATOR = "Coordinator"
private const val ATTR_UUID = "UUID"
private const val ATTR_ZONE_NAME = "ZoneName"
private const val ATTR_LOCATION = "Location"
private const val ATTR_CHANNEL_MAP_SET = "ChannelMapSet"
}
@@ -0,0 +1,37 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import okhttp3.HttpUrl
import timber.log.Timber
/**
* Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a
* standard UPnP service, exposed on port 1400 at
* /ZoneGroupTopology/Control. GetZoneGroupState returns the full
* network topology as one XML doc wrapped inside a SOAP arg.
*/
class ZoneGroupTopologyClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun getZoneGroupState(): List<SonosZoneGroup> {
val args = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetZoneGroupState",
args = emptyMap(),
)
val inner = args["ZoneGroupState"].orEmpty()
Timber.w(
"ZGT extracted ZoneGroupState (%d chars): %s",
inner.length,
inner.take(ZGT_LOG_TRUNCATE_CHARS),
)
return SonosTopology.parse(inner)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
const val ZGT_LOG_TRUNCATE_CHARS = 2048
}
}
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -164,6 +165,7 @@ fun MiniPlayer(
MiniRow(
track = track,
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
isLiked = isLiked,
onExpandClick = onExpandClick,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
private fun MiniRow(
track: TrackRef,
isPlaying: Boolean,
isUpnpLoading: Boolean,
isLiked: Boolean,
onExpandClick: () -> Unit,
onPlayPause: () -> Unit,
@@ -248,15 +251,39 @@ private fun MiniRow(
}
LikeButton(liked = isLiked, onToggle = onToggleLike)
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
TransportButton(
icon = if (isPlaying) Lucide.Pause else Lucide.Play,
description = if (isPlaying) "Pause" else "Play",
MiniPlayPauseButton(
isPlaying = isPlaying,
isUpnpLoading = isUpnpLoading,
onClick = onPlayPause,
)
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext)
}
}
@Composable
private fun MiniPlayPauseButton(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(MINI_PLAY_PAUSE_SPINNER_DP.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
private const val MINI_PLAY_PAUSE_SPINNER_DP = 24
@Composable
private fun TransportButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -50,10 +52,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -74,11 +85,13 @@ import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.DeviceChip
import com.fabledsword.minstrel.player.output.OutputPickerSheet
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
import com.fabledsword.minstrel.player.output.OutputRoute
import com.fabledsword.minstrel.player.output.RouteSnapshot
import kotlinx.coroutines.launch
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
@@ -129,27 +142,30 @@ fun NowPlayingScreen(
snackbarHostState.showSnackbar(msg)
}
}
LaunchedEffect(Unit) {
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
}
val track = state.currentTrack
if (track == null) {
// Session torn down (queue finished + auto-stop, or user cleared
// the queue from elsewhere). Pop back to whichever shell screen
// launched NowPlaying rather than stranding the user on an
// EmptyState with no escape. A short delay swallows the
// momentary null during MediaController IPC bind on cold-mount.
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(POP_GRACE_MS)
if (viewModel.uiState.value.currentTrack == null) {
navController.popBackStack()
}
}
NowPlayingNullTrackGuard(navController, viewModel)
return
}
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle()
val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() }
val dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() },
)
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
modifier = Modifier
.fillMaxSize()
.nestedScroll(dismissConnection)
.focusRequester(focusRequester)
.focusable()
.onKeyEvent(onKeyEvent),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
@@ -166,11 +182,32 @@ fun NowPlayingScreen(
navController = navController,
viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel,
outputViewModel = outputViewModel,
)
}
}
}
/**
* Null-track guard extracted from [NowPlayingScreen] to keep that
* function under detekt's LongMethod ceiling. Session torn down
* (queue finished + auto-stop, or user cleared the queue from
* elsewhere). Pops back after a short grace delay so a momentary
* null during MediaController IPC bind on cold-mount doesn't flash.
*/
@Composable
private fun NowPlayingNullTrackGuard(
navController: NavHostController,
viewModel: PlayerViewModel,
) {
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(POP_GRACE_MS)
if (viewModel.uiState.value.currentTrack == null) {
navController.popBackStack()
}
}
}
@Composable
private fun dominantGradient(top: Color): Brush {
val base = MaterialTheme.colorScheme.background
@@ -270,6 +307,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
)
}
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
@Composable
private fun NowPlayingBody(
inner: androidx.compose.foundation.layout.PaddingValues,
@@ -278,10 +316,10 @@ private fun NowPlayingBody(
navController: NavHostController,
viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
outputViewModel: OutputPickerViewModel,
) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
@@ -387,6 +425,7 @@ private fun PlaybackControlsBlock(
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
@@ -667,6 +706,7 @@ private fun ScrubTrack(fraction: Float, accent: Color) {
@Composable
private fun TransportRow(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onPrev: () -> Unit,
onPlayPause: () -> Unit,
onNext: () -> Unit,
@@ -685,13 +725,20 @@ private fun TransportRow(
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
strokeWidth = 3.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
}
}
IconButton(onClick = onNext) {
Icon(
@@ -703,3 +750,40 @@ private fun TransportRow(
}
}
}
/**
* Returns a key-event handler that intercepts volume-up/down when a UPnP
* route is active and routes the step through [ActiveUpnp.rendering].
* Volume is cached locally so rapid key presses don't each wait on a
* getVolume() round-trip. Returns false (not consumed) for every event
* when no UPnP route is active so the system handles volume normally.
*/
@Composable
private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope()
val cache = remember(activeUpnp?.routeId) { VolumeCache() }
return remember(activeUpnp) {
handler@{ event: KeyEvent ->
val rc = activeUpnp?.rendering ?: return@handler false
if (event.type != KeyEventType.KeyDown) return@handler false
val delta = when (event.key) {
Key.VolumeUp -> VOLUME_KEY_STEP
Key.VolumeDown -> -VOLUME_KEY_STEP
else -> return@handler false
}
scope.launch {
val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0
val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT)
cache.value = next
runCatching { rc.setVolume(next) }
}
true
}
}
}
private class VolumeCache(var value: Int? = null)
private const val VOLUME_KEY_STEP = 5
private const val VOLUME_MIN_PERCENT = 0
private const val VOLUME_MAX_PERCENT = 100
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor(
) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
fun play() = controller.play()
fun pause() = controller.pause()
@@ -0,0 +1,71 @@
package com.fabledsword.minstrel.playlists.data
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.api.ErrorCopy
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
/**
* Fetch a playlist and hand it to the player as a shuffled queue.
*
* Behavior matches the tile play-button contract used on Home and the
* Playlists list: refreshable system playlists go through systemShuffle
* (server-side rotation-aware order; tagging with the variant advances
* rotation), everything else uses refreshDetail. System playlists are
* then client-side shuffled so the tile feels random rather than
* "start at the rotation head". User playlists keep their authored
* order.
*
* Errors and empty mixes surface through [onMessage] for the caller to
* present as a snackbar / toast / etc. Returns when the player has
* accepted the queue (or an error path bailed).
*/
suspend fun playPlaylistShuffled(
playlist: PlaylistRef,
repository: PlaylistsRepository,
player: PlayerController,
onMessage: (String) -> Unit,
) {
val detail = fetchPlaylistDetail(playlist, repository, onMessage) ?: return
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
onMessage("Mix isn't ready yet - try again in a moment")
return
}
// Drift #564: bare systemVariant string (not "playlist:<variant>") --
// server's rotation matcher keys on the bare variant.
val source = if (playlist.refreshable) playlist.systemVariant else null
// System playlist tile play button == "pick a random song + shuffle
// the rest" UX. Server's rotation-aware order still drives rotation
// bookkeeping via `source`; the client shuffle just removes the
// deterministic "start at rotation head" feel.
val ordered = if (playlist.isSystem) tracks.shuffled() else tracks
player.setQueue(ordered, initialIndex = 0, source = source)
}
private suspend fun fetchPlaylistDetail(
playlist: PlaylistRef,
repository: PlaylistsRepository,
onMessage: (String) -> Unit,
): PlaylistDetailRef? = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
repository.systemShuffle(playlist.systemVariant)
} else {
repository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: TimeoutCancellationException,
) {
onMessage("Couldn't load playlist - check your connection")
null
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}")
null
}
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -23,11 +27,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
@@ -37,12 +44,19 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
// ─── ViewModel ───────────────────────────────────────────────────────
@@ -50,9 +64,25 @@ import javax.inject.Inject
@HiltViewModel
class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val player: PlayerController,
private val eventsStream: EventsStream,
connectivity: ConnectivityObserver,
) : ViewModel() {
private val poolMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from playlist tile play taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/** True when the device has no usable internet -- gates refreshable system tiles. */
val offline: StateFlow<Boolean> = connectivity.online
.map { !it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
init {
refresh()
// Live updates: a playlist created/updated/deleted from another
@@ -69,6 +99,15 @@ class PlaylistsListViewModel @Inject constructor(
runCatching { repository.refreshList() }
}
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
playPlaylistShuffled(playlist, repository, player) {
poolMessages.trySend(it)
}
}.join()
}
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll()
.map { list ->
@@ -88,6 +127,10 @@ fun PlaylistsListScreen(
navController: NavHostController,
viewModel: PlaylistsListViewModel = hiltViewModel(),
) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -97,8 +140,10 @@ fun PlaylistsListScreen(
currentRouteName = Playlists::class.qualifiedName,
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
@@ -117,7 +162,9 @@ fun PlaylistsListScreen(
)
is UiState.Success -> PlaylistsGrid(
playlists = s.data,
offline = offline,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onPlay = viewModel::playPlaylist,
)
}
}
@@ -127,7 +174,9 @@ fun PlaylistsListScreen(
@Composable
private fun PlaylistsGrid(
playlists: List<PlaylistRef>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlay: suspend (PlaylistRef) -> Unit,
) {
val systemPlaylists = playlists.filter { it.isSystem }
val userPlaylists = playlists.filter { !it.isSystem }
@@ -142,7 +191,15 @@ private fun PlaylistsGrid(
SectionHeader("System playlists")
}
items(items = systemPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
// Refreshable system tiles need server endpoints (systemShuffle);
// disable when offline. Empty mixes show a snackbar after tap.
playEnabled = playlist.trackCount > 0 &&
!(offline && playlist.refreshable),
)
}
}
if (userPlaylists.isNotEmpty()) {
@@ -150,7 +207,12 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists")
}
items(items = userPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
playEnabled = playlist.trackCount > 0,
)
}
}
}
@@ -0,0 +1,75 @@
package com.fabledsword.minstrel.player
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RemotePlayerStateTest {
@Test
fun `starts in idle state`() {
val state = RemotePlayerState()
assertFalse(state.isPlaying)
assertEquals(0L, state.positionMs)
assertEquals(0L, state.durationMs)
}
@Test
fun `applyPositionInfo updates position, duration, and trackNumber`() {
val state = RemotePlayerState()
state.applyPositionInfo(
positionMs = 65_000L, durationMs = 210_000L, trackUri = "x", trackNumber = 3,
)
assertEquals(65_000L, state.positionMs)
assertEquals(210_000L, state.durationMs)
assertEquals("x", state.currentTrackUri)
assertEquals(3, state.trackNumber)
}
@Test
fun `applyTransportPlaying flips isPlaying true`() {
val state = RemotePlayerState()
state.applyTransportPlaying()
assertTrue(state.isPlaying)
}
@Test
fun `applyTransportPaused flips isPlaying false`() {
val state = RemotePlayerState().apply { applyTransportPlaying() }
state.applyTransportPaused()
assertFalse(state.isPlaying)
}
@Test
fun `applyError resets to idle and records error`() {
val state = RemotePlayerState().apply { applyTransportPlaying() }
val ex = RuntimeException("disconnected")
state.applyError(ex)
assertFalse(state.isPlaying)
assertEquals(ex, state.lastError)
}
@Test
fun `recordPollFailure trips after threshold`() {
val state = RemotePlayerState()
// Threshold is 30 -- tolerate ~30s of screen-off WiFi sleep before
// declaring the remote dropped. Pre-threshold calls all return false.
repeat(DROP_THRESHOLD - 1) {
assertFalse(state.recordPollFailure())
}
assertTrue(state.recordPollFailure())
}
@Test
fun `recordPollSuccess clears the failure counter`() {
val state = RemotePlayerState()
repeat(DROP_THRESHOLD - 1) { state.recordPollFailure() }
state.recordPollSuccess()
assertFalse(state.recordPollFailure())
}
private companion object {
const val DROP_THRESHOLD = 30
}
}
@@ -0,0 +1,194 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class AVTransportClientTest {
private lateinit var server: MockWebServer
private lateinit var client: AVTransportClient
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
}
@AfterEach
fun tearDown() {
server.shutdown()
}
@Test
fun `seek sends Target in HH MM SS format`() = runTest {
server.enqueue(emptyResponse("Seek"))
client.seek(positionMs = 65_000L)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<Target>0:01:05</Target>")) { "body was $body" }
}
@Test
fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetPositionInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<Track>3</Track>
<TrackDuration>0:03:30</TrackDuration>
<TrackURI>http://x/y.mp3</TrackURI>
<RelTime>0:01:05</RelTime>
</u:GetPositionInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getPositionInfo()
assertEquals(3, info.track)
assertEquals(65_000L, info.relTimeMs)
assertEquals(210_000L, info.trackDurationMs)
assertEquals("http://x/y.mp3", info.trackUri)
}
@Test
fun `getTransportInfo maps PAUSED_PLAYBACK to PAUSED`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetTransportInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentTransportState>PAUSED_PLAYBACK</CurrentTransportState>
<CurrentTransportStatus>OK</CurrentTransportStatus>
<CurrentSpeed>1</CurrentSpeed>
</u:GetTransportInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getTransportInfo()
assertEquals(TransportState.PAUSED, info.state)
}
@Test
fun `getTransportInfo maps unknown state to UNKNOWN`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetTransportInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentTransportState>BUFFERING_PLAYBACK</CurrentTransportState>
<CurrentTransportStatus>OK</CurrentTransportStatus>
<CurrentSpeed>1</CurrentSpeed>
</u:GetTransportInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getTransportInfo()
assertEquals(TransportState.UNKNOWN, info.state)
}
@Test
fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest {
server.enqueue(emptyResponse("RemoveAllTracksFromQueue"))
client.removeAllTracksFromQueue()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("RemoveAllTracksFromQueue")) {
"SOAPACTION header missing action: ${request.getHeader("SOAPACTION")}"
}
val body = request.body.readUtf8()
assertTrue(body.contains("RemoveAllTracksFromQueue")) { "body: $body" }
}
@Test
fun `addURIToQueue sends EnqueuedURI and DIDL-Lite metadata`() = runTest {
server.enqueue(emptyResponse("AddURIToQueue"))
client.addURIToQueue(
uri = "http://x/y.mp3",
mime = "audio/mpeg",
title = "Song",
enqueuedURIPosition = 2,
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<EnqueuedURI>http://x/y.mp3</EnqueuedURI>")) {
"body missing EnqueuedURI: $body"
}
val positionTag = "<DesiredFirstTrackNumberEnqueued>2</DesiredFirstTrackNumberEnqueued>"
assertTrue(body.contains(positionTag)) { "body missing position: $body" }
assertTrue(body.contains("&lt;dc:title&gt;Song&lt;/dc:title&gt;")) {
"title missing in DIDL: $body"
}
assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" }
}
@Test
fun `next sends Next SOAP action`() = runTest {
server.enqueue(emptyResponse("Next"))
client.next()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Next\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `previous sends Previous SOAP action`() = runTest {
server.enqueue(emptyResponse("Previous"))
client.previous()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Previous\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `seekToTrack sends TRACK_NR unit with 1-based target`() = runTest {
server.enqueue(emptyResponse("Seek"))
client.seekToTrack(trackNumber = 4)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<Unit>TRACK_NR</Unit>")) { "body: $body" }
assertTrue(body.contains("<Target>4</Target>")) { "body: $body" }
}
@Test
fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI"))
client.setAVTransportURIWithMetadata(
uri = "https://example.com/track.mp3",
mime = "audio/mpeg",
title = "Song",
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<CurrentURI>https://example.com/track.mp3</CurrentURI>")) {
"CurrentURI should be sent as plain https: $body"
}
assertFalse(body.contains("x-rincon-mp3radio")) {
"x-rincon-mp3radio scheme should not appear: $body"
}
}
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:${action}Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
</s:Body>
</s:Envelope>""".trimIndent(),
)
}
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.player.output.upnp
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class BareUdnTest {
@Test
fun `strips uuid prefix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC".bareUdn())
}
@Test
fun `strips MR suffix`() {
assertEquals("RINCON_ABC", "RINCON_ABC_MR".bareUdn())
}
@Test
fun `strips MS suffix`() {
assertEquals("RINCON_ABC", "RINCON_ABC_MS".bareUdn())
}
@Test
fun `strips both uuid prefix and MR suffix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MR".bareUdn())
}
@Test
fun `strips both uuid prefix and MS suffix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MS".bareUdn())
}
@Test
fun `leaves already bare UDN unchanged`() {
assertEquals("RINCON_ABC", "RINCON_ABC".bareUdn())
}
@Test
fun `MR suffix comparison crosses MediaRenderer vs ZGT response`() {
val routeId = "uuid:RINCON_5CAAFD794B6401400_MR"
val zgtMemberUdn = "RINCON_5CAAFD794B6401400"
assertEquals(routeId.bareUdn(), zgtMemberUdn.bareUdn())
}
}
@@ -58,6 +58,7 @@ class DeviceDescriptionTest {
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
desc.renderingControlUrl?.toString(),
)
assertNull(desc.zoneGroupTopologyControlUrl)
}
@Test
@@ -81,6 +82,37 @@ class DeviceDescriptionTest {
assertNull(DeviceDescription.parse(xml, base))
}
@Test
fun `parses ZoneGroupTopology control URL when present`() {
val xml = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<UDN>uuid:RINCON_XYZ</UDN>
<friendlyName>Office</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ZoneGroupTopology:1</serviceType>
<controlURL>/ZoneGroupTopology/Control</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
val desc = DeviceDescription.parse(xml, base)
assertNotNull(desc)
assertEquals(
"http://192.168.1.50:1400/ZoneGroupTopology/Control",
desc.zoneGroupTopologyControlUrl?.toString(),
)
}
@Test
fun `handles missing optional fields`() {
val xml = """
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
class RenderingControlClientTest {
private lateinit var server: MockWebServer
private lateinit var client: RenderingControlClient
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
client = RenderingControlClient(SoapClient(OkHttpClient()), server.url("/RC"))
}
@AfterEach
fun tearDown() { server.shutdown() }
@Test
fun `getVolume parses CurrentVolume`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<CurrentVolume>42</CurrentVolume>
</u:GetVolumeResponse>
</s:Body>
</s:Envelope>""".trimIndent()))
assertEquals(42, client.getVolume())
val request = server.takeRequest()
val body = request.body.readUtf8()
assertTrue(body.contains("<Channel>Master</Channel>")) { body }
assertEquals(
"\"urn:schemas-upnp-org:service:RenderingControl:1#GetVolume\"",
request.getHeader("SOAPACTION"),
)
}
@Test
fun `setVolume clamps and sends DesiredVolume`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:SetVolumeResponse
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
</s:Body>
</s:Envelope>""".trimIndent()))
client.setVolume(150)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<DesiredVolume>100</DesiredVolume>")) { body }
}
@Test
fun `setVolume clamps below VOLUME_MIN to zero`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:SetVolumeResponse
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
</s:Body>
</s:Envelope>""".trimIndent()))
client.setVolume(-5)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<DesiredVolume>0</DesiredVolume>")) { body }
}
}
@@ -0,0 +1,109 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class SonosTopologyTest {
@Test
fun `single zone group with one member`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
val g = groups.first()
assertEquals("RINCON_A", g.coordinatorUdn)
assertEquals("Kitchen", g.name)
assertEquals(1, g.members.size)
}
@Test
fun `stereo pair collapses to one group with two members`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_L" ID="RINCON_L:2">
<ZoneGroupMember UUID="RINCON_L" ZoneName="Living Room"
Location="http://192.168.1.11:1400/xml/device_description.xml"
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
<ZoneGroupMember UUID="RINCON_R" ZoneName="Living Room"
Location="http://192.168.1.12:1400/xml/device_description.xml"
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
val g = groups.first()
assertEquals("RINCON_L", g.coordinatorUdn)
assertEquals("Living Room", g.name)
assertEquals(2, g.members.size)
assertNotNull(g.members[0].channelMapSet)
}
@Test
fun `multi-speaker group lists coordinator name`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_X" ID="RINCON_X:3">
<ZoneGroupMember UUID="RINCON_X" ZoneName="Office"/>
<ZoneGroupMember UUID="RINCON_Y" ZoneName="Bedroom"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals("Office", groups.first().name)
}
@Test
fun `malformed xml returns empty list`() {
assertEquals(emptyList(), SonosTopology.parse("<garbage"))
}
@Test
fun `parses inline non-escaped ZoneGroupState document`() {
// Some Sonos firmware sends the topology as nested elements with
// no escaping. After SoapClient.readUntilEndTag rebuilds a flat
// string, parse should still find the groups.
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
}
@Test
fun `parses escaped ZoneGroupState wrapped in entities`() {
val xml = """
&lt;ZoneGroupState&gt;
&lt;ZoneGroups&gt;
&lt;ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1"&gt;
&lt;ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/&gt;
&lt;/ZoneGroup&gt;
&lt;/ZoneGroups&gt;
&lt;/ZoneGroupState&gt;
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
}
}
+4
View File
@@ -65,6 +65,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
// Extension-bearing alias so Sonos's URL probe can identify the
// audio format from the path. The {ext} param is consumed by chi
// and ignored by the handler (which keys off {id}). See task #610.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
+79 -2
View File
@@ -3,9 +3,11 @@ package api
import (
"net/http"
"strconv"
"strings"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
@@ -23,6 +25,63 @@ type castTokenResponse struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
URL string `json:"url"`
// MIME and Title let the client build proper DIDL-Lite metadata for
// SetAVTransportURI. Sonos rejects empty DIDL with vendor error 1023;
// passing back the track's MIME + title here lets the client populate
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
MIME string `json:"mime"`
Title string `json:"title"`
}
// mimeForFormat maps the tracks.file_format column to an HTTP audio
// MIME type. Sonos requires the protocolInfo MIME on DIDL-Lite to match
// what the URL actually serves; "audio/*" wildcard is silently rejected.
// Unknown formats fall back to audio/mpeg — most Sonos firmware probes
// the URL anyway and recovers from a small MIME mismatch.
func mimeForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3", "mpeg":
return "audio/mpeg"
case "flac":
return "audio/flac"
case "aac", "m4a", "mp4":
return "audio/mp4"
case "ogg", "vorbis":
return "audio/ogg"
case "opus":
return "audio/opus"
case "wav", "wave":
return "audio/wav"
default:
return "audio/mpeg"
}
}
// extForFormat maps the tracks.file_format column to a path-safe file
// extension. Sonos firmware gates duration probing on the URL path
// extension (Content-Type header alone is insufficient) -- without a
// recognizable extension, Sonos reports TrackDuration=0 and seeks
// trigger auto-advance because every position past 0 looks past-the-
// end. Defaults to "mp3" for unknown formats. See task #610.
func extForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3", "mpeg":
return "mp3"
case "flac":
return "flac"
case "aac":
return "aac"
case "m4a", "mp4":
return "m4a"
case "ogg", "vorbis":
return "ogg"
case "opus":
return "opus"
case "wav", "wave":
return "wav"
default:
return "mp3"
}
}
// handleCastStreamToken issues a short-lived HMAC stream token for the
@@ -52,6 +111,14 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
return
}
// Track lookup for the DIDL-Lite metadata the client builds for
// SetAVTransportURI. A missing track is a 404 — there's nothing to
// cast in that case.
track, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackUUID)
if err != nil {
writeErr(w, apierror.NotFound("track"))
return
}
expSec := clampExpSeconds(req.ExpSeconds)
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
@@ -74,10 +141,20 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
host = h
}
// Include the file extension in the path so Sonos's URL probe sees a
// recognizable audio file. Without it, Sonos reports TrackDuration=0
// and seeks past 0s land "after the end" -> early track-skip.
url := scheme + "://" + host + "/api/tracks/" + req.TrackID +
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
"/stream." + extForFormat(track.FileFormat) +
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
writeJSON(w, http.StatusOK, castTokenResponse{
Token: token,
Exp: exp,
URL: url,
MIME: mimeForFormat(track.FileFormat),
Title: track.Title,
})
}
// clampExpSeconds applies the [60, 86400] window with a 6h default for
+22 -6
View File
@@ -10,15 +10,21 @@ import (
"time"
)
const testTrackUUID = "11111111-1111-1111-1111-111111111111"
// nonExistentTrackUUID is used by tests that exercise paths which don't
// require the track to actually exist (auth/UUID-shape rejection).
const nonExistentTrackUUID = "11111111-1111-1111-1111-111111111111"
func TestCastStreamToken_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
artist := seedArtist(t, pool, "Artist")
album := seedAlbum(t, pool, artist.ID, "Album", 0)
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
trackID := uuidToString(track.ID)
h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID,
TrackID: trackID,
ExpSeconds: 3600,
})
if err != nil {
@@ -44,10 +50,16 @@ func TestCastStreamToken_HappyPath(t *testing.T) {
if !strings.Contains(resp.URL, "token="+resp.Token) {
t.Fatalf("URL missing token query: %s", resp.URL)
}
if !strings.Contains(resp.URL, "/api/tracks/"+testTrackUUID+"/stream") {
if !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/stream") {
t.Fatalf("URL missing stream path: %s", resp.URL)
}
if !VerifyStreamToken(h.streamSecret, testTrackUUID, resp.Exp, resp.Token) {
// Stream URL must carry a file extension so Sonos's URL probe can
// identify the audio format (see task #610). Track seeded above is
// .flac via seedTrack's default file_format.
if !strings.Contains(resp.URL, "/stream.flac?") {
t.Fatalf("URL missing file-extension segment: %s", resp.URL)
}
if !VerifyStreamToken(h.streamSecret, trackID, resp.Exp, resp.Token) {
t.Fatal("returned token does not verify")
}
}
@@ -77,7 +89,7 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
h, _ := testHandlers(t)
h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{TrackID: testTrackUUID})
body, err := json.Marshal(castTokenRequest{TrackID: nonExistentTrackUUID})
if err != nil {
t.Fatalf("marshal: %v", err)
}
@@ -96,11 +108,15 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
artist := seedArtist(t, pool, "Artist")
album := seedAlbum(t, pool, artist.ID, "Album", 0)
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
trackID := uuidToString(track.ID)
h.streamSecret = []byte("cast-token-test-secret")
// Request 1 second (below min 60), expect clamp to 60s.
body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID,
TrackID: trackID,
ExpSeconds: 1,
})
if err != nil {