diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt index 771ef03a..e291f4a5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt @@ -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 `` and `` 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 = "", ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index cf5a3649..eb913061 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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:" — 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() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt new file mode 100644 index 00000000..f1f610c1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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:#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(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 { + 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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index ae4f3eb3..7458a730 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -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() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 0604cd03..a6af30d3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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 " to the user. + */ + val dropEvents: SharedFlow = 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.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 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 8bfe3ccb..dd28ca5e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -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( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val dropEvents: SharedFlow = 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) + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt index fedaf877..4a101ec0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt @@ -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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt new file mode 100644 index 00000000..0ffde318 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt new file mode 100644 index 00000000..ffff44bf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt @@ -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)) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt new file mode 100644 index 00000000..601925ac --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt @@ -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(null) + val active: StateFlow = 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(null) + val target: StateFlow = targetInternal.asStateFlow() + + fun set(active: ActiveUpnp?) { internal.value = active } + + fun setTarget(routeId: String?) { targetInternal.value = routeId } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 1f6a178b..48fdc0de 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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(null) + private val selectUpnpMutex = Mutex() + val routesState: StateFlow = 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, + 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, + 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): List { - 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): List { + 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 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt index 173fcb46..4a9039c3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt index 19362d60..6e3cd81a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt @@ -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 = controller.routesState @@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor( initialValue = controller.routesState.value, ) + val activeUpnp: StateFlow = activeUpnpHolder.active + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + private val sheetVisibleInternal = MutableStateFlow(false) val sheetVisible: StateFlow = sheetVisibleInternal.asStateFlow() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index a7897087..11dbbe58 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -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: + * + * + * ... + * object.item.audioItem.musicTrack + * ... + * + * + * 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 + // even if we don't use Rincon elements directly, and + // (b) a 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("") + append("") + append("").append(xmlEscape(safeTitle)).append("") + append("object.item.audioItem.musicTrack") + append("").append(xmlEscape(uri)).append("") + append("") + append("RINCON_AssociatedZPUDN") + append("") + append("") + append("") + } + } + + 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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt index e672deb6..4dcb8f94 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt @@ -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 = "" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClient.kt new file mode 100644 index 00000000..a5f20c9a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClient.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt index 4bf6ab51..4a25fde3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -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("") } - private fun xmlEscape(v: String): String = v - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - private fun parseResponseArgs(body: String, action: String): Map { 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("') + } + } + 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, "", "") ?: "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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 33427cd0..1989f904 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -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>(emptyList()) val routes: StateFlow> = routesInternal.asStateFlow() + private val sonosTopologyInternal = MutableStateFlow>(emptyList()) + val sonosTopology: StateFlow> = 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 + * 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 { + 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 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") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt index 029d300b..b1ffbbba 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt @@ -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 `` 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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt new file mode 100644 index 00000000..a823de2d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt @@ -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, +) + +data class SonosZoneMember( + val udn: String, + val roomName: String, + val location: String?, + val channelMapSet: String?, +) + +object SonosTopology { + + fun parse(xml: String): List { + val effective = if (xml.contains("<ZoneGroup")) { + unescapeXmlEntities(xml) + } else { + xml + } + return runCatching { parseStrict(effective) }.getOrDefault(emptyList()) + } + + private fun unescapeXmlEntities(s: String): String = s + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") // must be last to avoid double-decoding + + private fun parseStrict(xml: String): List { + val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) + setInput(xml.reader()) + } + val groups = mutableListOf() + var currentCoordinator: String? = null + var currentMembers: MutableList? = 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" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt new file mode 100644 index 00000000..35cc0665 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt @@ -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 { + 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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index b2061931..1e378ff5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 4115a767..7fb99d3d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt index b9a32e3f..d7a4952a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -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 = controller.uiState + val dropEvents: SharedFlow = controller.dropEvents fun play() = controller.play() fun pause() = controller.pause() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt new file mode 100644 index 00000000..aca9f650 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt @@ -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:") -- + // 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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index ee730e76..b5302718 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -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(Channel.BUFFERED) + + /** Transient snackbar messages from playlist tile play taps. */ + val transientMessages: Flow = poolMessages.receiveAsFlow() + + /** True when the device has no usable internet -- gates refreshable system tiles. */ + val offline: StateFlow = 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>> = 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, + 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, + ) } } } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt new file mode 100644 index 00000000..bb0c0224 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt @@ -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 + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt new file mode 100644 index 00000000..a76ba348 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -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("0:01:05")) { "body was $body" } + } + + @Test + fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + + + + 3 + 0:03:30 + http://x/y.mp3 + 0:01:05 + + + """.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( + """ + + + + PAUSED_PLAYBACK + OK + 1 + + + """.trimIndent(), + ), + ) + val info = client.getTransportInfo() + assertEquals(TransportState.PAUSED, info.state) + } + + @Test + fun `getTransportInfo maps unknown state to UNKNOWN`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + + + + BUFFERING_PLAYBACK + OK + 1 + + + """.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("http://x/y.mp3")) { + "body missing EnqueuedURI: $body" + } + val positionTag = "2" + assertTrue(body.contains(positionTag)) { "body missing position: $body" } + assertTrue(body.contains("<dc:title>Song</dc:title>")) { + "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("TRACK_NR")) { "body: $body" } + assertTrue(body.contains("4")) { "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("https://example.com/track.mp3")) { + "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( + """ + + + + + """.trimIndent(), + ) +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/BareUdnTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/BareUdnTest.kt new file mode 100644 index 00000000..28187353 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/BareUdnTest.kt @@ -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()) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt index e633415c..2668d066 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt @@ -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 = """ + + + + uuid:RINCON_XYZ + Office + Sonos, Inc. + Sonos One + + + urn:schemas-upnp-org:service:AVTransport:1 + /MediaRenderer/AVTransport/Control + + + urn:schemas-upnp-org:service:ZoneGroupTopology:1 + /ZoneGroupTopology/Control + + + + + """.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 = """ diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt new file mode 100644 index 00000000..3fcdf6f8 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt @@ -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( + """ + + + + 42 + + + """.trimIndent())) + assertEquals(42, client.getVolume()) + val request = server.takeRequest() + val body = request.body.readUtf8() + assertTrue(body.contains("Master")) { 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( + """ + + + + + """.trimIndent())) + client.setVolume(150) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("100")) { body } + } + + @Test + fun `setVolume clamps below VOLUME_MIN to zero`() = runTest { + server.enqueue(MockResponse().setBody( + """ + + + + + """.trimIndent())) + client.setVolume(-5) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("0")) { body } + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt new file mode 100644 index 00000000..23caef85 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt @@ -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 = """ + + + + + + + + """.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 = """ + + + + + + + + + """.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 = """ + + + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals("Office", groups.first().name) + } + + @Test + fun `malformed xml returns empty list`() { + assertEquals(emptyList(), SonosTopology.parse(" + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals(1, groups.size) + } + + @Test + fun `parses escaped ZoneGroupState wrapped in entities`() { + 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) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 4a644661..e95a26a1 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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)) diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index 37751150..5f3f3046 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -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 + // `` and `` 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 diff --git a/internal/api/cast_token_test.go b/internal/api/cast_token_test.go index 0687b57d..526f50ed 100644 --- a/internal/api/cast_token_test.go +++ b/internal/api/cast_token_test.go @@ -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 {