RemoteUpnpRenderer.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.upnp.renderer |
| 2 | ||
| 3 | import dev.stave.domain.model.DidlItem | |
| 4 | import dev.stave.domain.model.RemoteRenderer | |
| 5 | import dev.stave.domain.renderer.PlaybackState | |
| 6 | import dev.stave.domain.renderer.RepeatMode | |
| 7 | import dev.stave.domain.renderer.Renderer | |
| 8 | import dev.stave.upnp.UpnpServiceHolder | |
| 9 | import kotlinx.coroutines.CancellationException | |
| 10 | import kotlinx.coroutines.CoroutineScope | |
| 11 | import kotlinx.coroutines.Dispatchers | |
| 12 | import kotlinx.coroutines.Job | |
| 13 | import kotlinx.coroutines.SupervisorJob | |
| 14 | import kotlinx.coroutines.cancel | |
| 15 | import kotlinx.coroutines.delay | |
| 16 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 17 | import kotlinx.coroutines.flow.StateFlow | |
| 18 | import kotlinx.coroutines.flow.asStateFlow | |
| 19 | import kotlinx.coroutines.flow.update | |
| 20 | import kotlinx.coroutines.launch | |
| 21 | import kotlinx.coroutines.suspendCancellableCoroutine | |
| 22 | import kotlinx.coroutines.withContext | |
| 23 | import org.jupnp.controlpoint.ActionCallback | |
| 24 | import org.jupnp.controlpoint.SubscriptionCallback | |
| 25 | import org.jupnp.model.action.ActionException | |
| 26 | import org.jupnp.model.action.ActionInvocation | |
| 27 | import org.jupnp.model.gena.CancelReason | |
| 28 | import org.jupnp.model.gena.GENASubscription | |
| 29 | import org.jupnp.model.message.UpnpResponse | |
| 30 | import org.jupnp.model.meta.RemoteDevice | |
| 31 | import org.jupnp.model.meta.RemoteService | |
| 32 | import org.jupnp.model.types.UDAServiceType | |
| 33 | import org.jupnp.support.avtransport.lastchange.AVTransportLastChangeParser | |
| 34 | import org.jupnp.support.avtransport.lastchange.AVTransportVariable | |
| 35 | import org.jupnp.support.lastchange.Event | |
| 36 | import org.jupnp.support.lastchange.EventedValue | |
| 37 | import org.jupnp.support.renderingcontrol.lastchange.RenderingControlLastChangeParser | |
| 38 | import org.jupnp.support.renderingcontrol.lastchange.RenderingControlVariable | |
| 39 | import kotlin.coroutines.resume | |
| 40 | import kotlin.time.Duration | |
| 41 | import kotlin.time.Duration.Companion.milliseconds | |
| 42 | import kotlin.time.Duration.Companion.seconds | |
| 43 | ||
| 44 | /** | |
| 45 | * A [Renderer] implementation that drives a REMOTE UPnP MediaRenderer device discovered on | |
| 46 | * the LAN. The remote device is the actual audio source (another Earhorn instance, a Linn | |
| 47 | * streamer, gmediarender, BubbleUPnP receiver, etc.) — this class is the control-point | |
| 48 | * adapter that makes a remote renderer look like a local one to the rest of the app. | |
| 49 | * | |
| 50 | * Two halves of behavior: | |
| 51 | * | |
| 52 | * 1. **Outgoing actions.** [play], [pause], [stop], [seekTo], [next], [previous], | |
| 53 | * [setVolume] each translate to a UPnP action invoked on the remote device's | |
| 54 | * AVTransport or RenderingControl service. We use jUPnP's `ControlPoint.execute` | |
| 55 | * with [ActionCallback], wrapped in [suspendCancellableCoroutine] so callers can | |
| 56 | * `suspend` on completion the same way they do with the local renderer. | |
| 57 | * | |
| 58 | * 2. **Incoming state.** We open GENA subscriptions to the remote device's AVTransport | |
| 59 | * and RenderingControl `LastChange` evented state variables. Push notifications | |
| 60 | * from the renderer arrive as [eventReceived] callbacks; we parse the LastChange | |
| 61 | * payload with the service-specific parser and update [state] accordingly. Plus | |
| 62 | * a fallback poller for position (which UPnP doesn't event — it's pull-only via | |
| 63 | * `GetPositionInfo`). | |
| 64 | * | |
| 65 | * **The queue is ours.** UPnP AVTransport has no protocol-level queue, so this | |
| 66 | * class keeps the queue and feeds the device one track at a time | |
| 67 | * (SetAVTransportURI), pre-loading the next one with SetNextAVTransportURI for | |
| 68 | * gapless playback. Two ways to move on when a track ends, as on Android: | |
| 69 | * | |
| 70 | * - **Gapless:** the device starts the pre-loaded next track itself and reports | |
| 71 | * the new `CurrentTrackURI` ([handleCurrentTrackUriChange]). | |
| 72 | * - **Fallback:** many devices ignore SetNext (TVs, some Cast bridges, | |
| 73 | * gmediarender builds), and GENA events may never arrive (a firewall on this | |
| 74 | * computer is enough). The poller asks for the transport state every second | |
| 75 | * while playing and every ten seconds otherwise (Android parity | |
| 76 | * 2026-09-02), and when the device goes STOPPED within a few seconds of the | |
| 77 | * end of OUR track we send the next one ([onRemoteStopped]). Any other stop | |
| 78 | * is a stop (Android parity, 2026-09-02). | |
| 79 | * | |
| 80 | * Next / Previous, repeat and shuffle all act on our queue (Android parity, 2026-10-29, | |
| 81 | * Android): the device only ever holds one track, so its own Next or REPEAT_ALL | |
| 82 | * would do nothing useful. | |
| 83 | * | |
| 84 | * **Lifecycle.** | |
| 85 | * | |
| 86 | * The coordinator (next iteration) constructs a fresh [RemoteUpnpRenderer] each time | |
| 87 | * the user picks a different remote renderer, calls [attach] to open subscriptions, | |
| 88 | * and calls [detach] when switching away or shutting down. [detach] cancels the | |
| 89 | * subscription and stops the position poller cleanly so we don't leak GENA listeners | |
| 90 | * on the remote device — even Earhorn-to-Earhorn subscriptions don't auto-clean up | |
| 91 | * if the controller process dies abruptly, so detaching is hygienic for graceful | |
| 92 | * shutdown. | |
| 93 | */ | |
| 94 | class RemoteUpnpRenderer( | |
| 95 | private val target: RemoteRenderer, | |
| 96 | private val holder: UpnpServiceHolder, | |
| 97 | ) : Renderer { | |
| 98 | ||
| 99 | /** UDN of the remote renderer this instance is bound to. Public so the | |
| 100 | * coordinator can match an attached instance against the user's selection | |
| 101 | * without holding a separate lookup table. Stable for the life of the | |
| 102 | * instance — to drive a different renderer, build a new instance. */ | |
| 103 | val targetUdn: String get() = target.udn | |
| 104 | ||
| 105 | /** Display name of the remote — likewise public for the same reason. */ | |
| 106 | val targetFriendlyName: String get() = target.friendlyName | |
| 107 | ||
| 108 | private val _state = MutableStateFlow(PlaybackState()) | |
| 109 | override val state: StateFlow<PlaybackState> = _state.asStateFlow() | |
| 110 | ||
| 111 | // ---- Queue state -------------------------------------------------------- | |
| 112 | ||
| 113 | /** | |
| 114 | * Local queue maintained on the control-point side. UPnP AVTransport has no | |
| 115 | * queue concept at the protocol level — `SetAVTransportURI` loads one track | |
| 116 | * and `SetNextAVTransportURI` pre-loads one more, that's the entire surface. | |
| 117 | * To support multi-track albums / playlists / "queue this after current", | |
| 118 | * the control point (us) owns queue state and feeds tracks to the renderer | |
| 119 | * one at a time, using `SetNextAVTransportURI` for gapless transitions. | |
| 120 | * | |
| 121 | * Every read and write happens on [queueCtx]: user actions, GENA events and | |
| 122 | * the poller all used to touch it from different threads, and a track | |
| 123 | * ending while the user removed a row could throw out of `queue.toList()`. | |
| 124 | */ | |
| 125 | private val queue: MutableList<DidlItem.Track> = mutableListOf() | |
| 126 | private var queueIndex: Int = -1 | |
| 127 | ||
| 128 | /** One thread at a time for the queue and the advance bookkeeping below. | |
| 129 | * Network calls suspend and free it, so calls can interleave between | |
| 130 | * steps, but no two non-suspending sections ever run together. FIFO, so | |
| 131 | * GENA events are handled in the order they arrived. */ | |
| 132 | private val queueCtx = Dispatchers.IO.limitedParallelism(1) | |
| 133 | ||
| 134 | /** True after a Stop from anywhere (ours, the device's buttons, another | |
| 135 | * app): the next STOPPED must not advance. Cleared by [play]. */ | |
| 136 | @Volatile private var userRequestedStop = false | |
| 137 | ||
| 138 | /** When we last sent a track (or saw the device move to our next one). A | |
| 139 | * STOPPED within [SEND_GRACE_MS] of that is the device switching tracks, | |
| 140 | * never "our track ended" (Android parity, 2026-09-02). */ | |
| 141 | @Volatile private var lastTrackSentAt = 0L | |
| 142 | ||
| 143 | /** Position seen at the last poll while playing; decides whether a STOPPED | |
| 144 | * means "our track finished" or "someone stopped it". */ | |
| 145 | @Volatile private var lastSeenPosition: Duration = Duration.ZERO | |
| 146 | ||
| 147 | /** URI of the track we sent most recently, and what the device calls it if | |
| 148 | * it reports a rewritten form (a proxy or a redirect) — see | |
| 149 | * [handleCurrentTrackUriChange]. The previous pair lets a late echo of the | |
| 150 | * track we just replaced be recognised as stale instead of a hijack. */ | |
| 151 | private var currentRemoteUri: String? = null | |
| 152 | private var currentRemoteAlias: String? = null | |
| 153 | private var previousRemoteUri: String? = null | |
| 154 | private var previousRemoteAlias: String? = null | |
| 155 | ||
| 156 | /** Queue order before shuffle was turned on; restored when it goes off. */ | |
| 157 | private var preShuffleOrder: List<DidlItem.Track>? = null | |
| 158 | ||
| 159 | /** | |
| 160 | * URI we just told the renderer to play (via SetAVTransportURI). Used to | |
| 161 | * suppress the auto-advance detection logic in [parseAvtLastChange] — | |
| 162 | * when the renderer echoes back the URI we just set, we don't want to | |
| 163 | * interpret that as an auto-advance event. | |
| 164 | */ | |
| 165 | @Volatile | |
| 166 | private var pendingExpectedUri: String? = null | |
| 167 | ||
| 168 | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) | |
| 169 | ||
| 170 | /** Subscription tickets we need to cancel on detach. jUPnP's | |
| 171 | * [SubscriptionCallback.end] sends the UNSUBSCRIBE request to the renderer. */ | |
| 172 | private var avtSubscription: SubscriptionCallback? = null | |
| 173 | private var rcSubscription: SubscriptionCallback? = null | |
| 174 | ||
| 175 | /** Position poller. UPnP doesn't event the current track position — it's only | |
| 176 | * queryable via [GetPositionInfo]. We poll once a second while we've seen a | |
| 177 | * PLAYING transport state, and stop while paused/stopped to avoid network noise. */ | |
| 178 | private var positionPollerJob: Job? = null | |
| 179 | ||
| 180 | // ---- Lifecycle ---------------------------------------------------------- | |
| 181 | ||
| 182 | /** | |
| 183 | * Open the GENA subscriptions and prime [state] with an initial snapshot. Idempotent; | |
| 184 | * calling twice is a no-op. Doesn't throw — failures are logged and the renderer | |
| 185 | * comes up in a degraded "connected but no events" mode that callers can still issue | |
| 186 | * actions against (each action runs an independent invocation). | |
| 187 | */ | |
| 188 | fun attach() { | |
| 189 | if (avtSubscription != null) return // already attached | |
| 190 | log("attach() target=${target.friendlyName} udn=${target.udn}") | |
| 191 | ||
| 192 | val device = findRemoteDevice() | |
| 193 | if (device == null) { | |
| 194 | log("Device with UDN ${target.udn} is not in the registry — cannot attach") | |
| 195 | return | |
| 196 | } | |
| 197 | ||
| 198 | // Initial best-effort state pull via individual GetXxx actions. The renderer | |
| 199 | // will then keep us in sync via GENA events. If GENA fails (some bargain-bin | |
| 200 | // renderers don't fire events even though they accept the subscription), the | |
| 201 | // initial snapshot is at least useful and the action surface still works. | |
| 202 | scope.launch(queueCtx) { | |
| 203 | catching("pullInitialAvtState") { pullInitialAvtState(device) } | |
| 204 | catching("pullInitialVolume") { pullInitialVolume(device) } | |
| 205 | } | |
| 206 | ||
| 207 | device.findAvtService()?.let { svc -> | |
| 208 | val cb = object : SubscriptionCallback(svc) { | |
| 209 | override fun established(s: GENASubscription<*>) { | |
| 210 | log("AVT subscription established") | |
| 211 | } | |
| 212 | override fun ended( | |
| 213 | s: GENASubscription<*>, | |
| 214 | reason: CancelReason?, | |
| 215 | resp: UpnpResponse?, | |
| 216 | ) { | |
| 217 | log("AVT subscription ended reason=$reason rc=${resp?.statusCode}") | |
| 218 | } | |
| 219 | override fun eventReceived(s: GENASubscription<*>) { | |
| 220 | val raw = s.currentValues["LastChange"]?.value as? String ?: return | |
| 221 | scope.launch(queueCtx) { onAvtLastChange(raw) } | |
| 222 | } | |
| 223 | override fun eventsMissed(s: GENASubscription<*>, count: Int) { | |
| 224 | log("AVT subscription missed $count event(s) — state may be stale") | |
| 225 | } | |
| 226 | override fun failed( | |
| 227 | s: GENASubscription<*>?, | |
| 228 | resp: UpnpResponse?, | |
| 229 | ex: Exception?, | |
| 230 | defaultMsg: String, | |
| 231 | ) { | |
| 232 | log("AVT subscription failed: $defaultMsg ${ex?.message ?: ""}") | |
| 233 | } | |
| 234 | } | |
| 235 | holder.controlPoint?.execute(cb) | |
| 236 | avtSubscription = cb | |
| 237 | } ?: log("Remote device has no AVTransport service — degraded mode") | |
| 238 | ||
| 239 | device.findRcService()?.let { svc -> | |
| 240 | val cb = object : SubscriptionCallback(svc) { | |
| 241 | override fun established(s: GENASubscription<*>) {} | |
| 242 | override fun ended( | |
| 243 | s: GENASubscription<*>, | |
| 244 | reason: CancelReason?, | |
| 245 | resp: UpnpResponse?, | |
| 246 | ) {} | |
| 247 | override fun eventReceived(s: GENASubscription<*>) { | |
| 248 | val raw = s.currentValues["LastChange"]?.value as? String ?: return | |
| 249 | scope.launch(queueCtx) { parseRcLastChange(raw) } | |
| 250 | } | |
| 251 | override fun eventsMissed(s: GENASubscription<*>, count: Int) {} | |
| 252 | override fun failed( | |
| 253 | s: GENASubscription<*>?, | |
| 254 | resp: UpnpResponse?, | |
| 255 | ex: Exception?, | |
| 256 | defaultMsg: String, | |
| 257 | ) { | |
| 258 | log("RC subscription failed: $defaultMsg") | |
| 259 | } | |
| 260 | } | |
| 261 | holder.controlPoint?.execute(cb) | |
| 262 | rcSubscription = cb | |
| 263 | } ?: log("Remote device has no RenderingControl service — volume disabled") | |
| 264 | ||
| 265 | startPositionPoller() | |
| 266 | } | |
| 267 | ||
| 268 | /** Cancel GENA subscriptions, stop pollers, and release the coroutine scope. | |
| 269 | * Safe to call multiple times; subsequent calls are no-ops. */ | |
| 270 | fun detach() { | |
| 271 | log("detach() target=${target.friendlyName}") | |
| 272 | runCatching { avtSubscription?.end() } | |
| 273 | runCatching { rcSubscription?.end() } | |
| 274 | avtSubscription = null | |
| 275 | rcSubscription = null | |
| 276 | positionPollerJob?.cancel() | |
| 277 | positionPollerJob = null | |
| 278 | scope.cancel() | |
| 279 | } | |
| 280 | ||
| 281 | // ---- Outgoing actions (Renderer interface) ------------------------------ | |
| 282 | ||
| 283 | override suspend fun play() { | |
| 284 | userRequestedStop = false | |
| 285 | invokeAvtAction("Play", mapOf("Speed" to "1")) | |
| 286 | } | |
| 287 | ||
| 288 | override suspend fun pause() { | |
| 289 | invokeAvtAction("Pause") | |
| 290 | } | |
| 291 | ||
| 292 | override suspend fun stop(): Unit = withContext(queueCtx) { | |
| 293 | userRequestedStop = true | |
| 294 | // Clear the pre-loaded next URI FIRST. Otherwise some renderers | |
| 295 | // (specifically TVs with the AVTransport spec's "auto-play next URI | |
| 296 | // on STOPPED transition" behavior) jump straight from the Stop | |
| 297 | // command into playing the gapless-prefetched track. The user sees: | |
| 298 | // "I hit Stop, the song stopped, then a different song started." We | |
| 299 | // need to remove the receiver's idea of "what to play next" before | |
| 300 | // we tell it to stop, so there's nothing for it to advance into. | |
| 301 | // | |
| 302 | // Order matters: clear-next BEFORE stop. If we did stop first, the | |
| 303 | // receiver could trigger the next-URI before our clear-next reached | |
| 304 | // it. Failures here are swallowed — if the clear-next fails, the | |
| 305 | // worst case is the symptom we're trying to fix; we still try the | |
| 306 | // Stop after. | |
| 307 | catching("clear next") { | |
| 308 | invokeAvtAction( | |
| 309 | "SetNextAVTransportURI", | |
| 310 | mapOf("NextURI" to "", "NextURIMetaData" to ""), | |
| 311 | ) | |
| 312 | } | |
| 313 | invokeAvtAction("Stop") | |
| 314 | Unit | |
| 315 | } | |
| 316 | ||
| 317 | override suspend fun seekTo(position: Duration) { | |
| 318 | invokeAvtAction( | |
| 319 | "Seek", | |
| 320 | mapOf( | |
| 321 | "Unit" to "REL_TIME", | |
| 322 | "Target" to formatDurationAsUpnpClock(position), | |
| 323 | ) | |
| 324 | ) | |
| 325 | } | |
| 326 | ||
| 327 | /** Next track in OUR queue (Android, 2026-10-29): the device only | |
| 328 | * holds one URI, so its own Next does nothing on most renderers. Wraps to | |
| 329 | * the start under repeat ALL. With no queue of ours (we attached to | |
| 330 | * something already playing), the device's own Next is all there is. */ | |
| 331 | override suspend fun next(): Unit = withContext(queueCtx) { | |
| 332 | when { | |
| 333 | queue.isEmpty() -> { invokeAvtAction("Next"); Unit } | |
| 334 | queueIndex < queue.lastIndex -> { sendTrackAt(queueIndex + 1, autoplay = true); Unit } | |
| 335 | _state.value.repeatMode == RepeatMode.ALL -> { sendTrackAt(0, autoplay = true); Unit } | |
| 336 | else -> Unit // end of the queue | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | /** Previous track in OUR queue, stepping back past tracks the device can't | |
| 341 | * fetch (Android parity, 2026-10-29). On the first track it starts that track | |
| 342 | * over; with no queue of ours it is the device's own Previous. */ | |
| 343 | override suspend fun previous(): Unit = withContext(queueCtx) { | |
| 344 | when { | |
| 345 | queue.isEmpty() -> { invokeAvtAction("Previous"); Unit } | |
| 346 | queueIndex > 0 -> { | |
| 347 | if (!sendTrackAt(queueIndex - 1, autoplay = true, step = -1)) seekTo(Duration.ZERO) | |
| 348 | } | |
| 349 | else -> seekTo(Duration.ZERO) | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | override suspend fun setVolume(volumePercent: Int) { | |
| 354 | val v = volumePercent.coerceIn(0, 100) | |
| 355 | // Optimistic, like the local renderer: repeated scroll notches each | |
| 356 | // start from the value just asked for, not from a stale one that only | |
| 357 | // an event (if any) would refresh. | |
| 358 | _state.update { it.copy(volumePercent = v) } | |
| 359 | invokeRcAction( | |
| 360 | "SetVolume", | |
| 361 | mapOf( | |
| 362 | "Channel" to "Master", | |
| 363 | "DesiredVolume" to v.toString(), | |
| 364 | ) | |
| 365 | ) | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Replace the queue with [tracks] and start playing at [startIndex]. | |
| 370 | * | |
| 371 | * The track's [DidlItem.Track.resourceUrl] must be reachable from the remote | |
| 372 | * renderer's network position. For Earhorn-served music this Just Works | |
| 373 | * because everyone's on the same LAN and the URL is a stable HTTP URL on | |
| 374 | * the UPnP server. | |
| 375 | */ | |
| 376 | override suspend fun load( | |
| 377 | tracks: List<DidlItem.Track>, | |
| 378 | startIndex: Int, | |
| 379 | autoplay: Boolean, | |
| 380 | ): Unit = withContext(queueCtx) { | |
| 381 | queue.clear() | |
| 382 | userRequestedStop = false | |
| 383 | if (tracks.isEmpty()) { | |
| 384 | queueIndex = -1 | |
| 385 | preShuffleOrder = null | |
| 386 | publishQueueState() | |
| 387 | return@withContext | |
| 388 | } | |
| 389 | queue.addAll(tracks) | |
| 390 | queueIndex = startIndex.coerceIn(0, queue.lastIndex) | |
| 391 | // Shuffle on: the new list is the "in order" one; only what comes after | |
| 392 | // the start track is shuffled, as for the local renderer. | |
| 393 | preShuffleOrder = if (_state.value.shuffleEnabled) { | |
| 394 | queue.toList().also { shuffleTailAfterCurrent() } | |
| 395 | } else null | |
| 396 | // Publish the queue state to the UI BEFORE we issue the SetAVTransportURI | |
| 397 | // network call. Without this, an outage / slow remote / wedged HTTP | |
| 398 | // makes the user's queue silently disappear from their phone screen | |
| 399 | // until either the call completes or the user re-navigates — they end | |
| 400 | // up thinking "I had a queue, picked the desktop, and my queue vanished." | |
| 401 | // Publishing first means the queue is visibly present immediately; | |
| 402 | // sendTrackAt publishes again at the end with the post-network | |
| 403 | // state (current-track / position) but the queue list stays consistent | |
| 404 | // throughout. | |
| 405 | publishQueueState() | |
| 406 | // Android parity (2026-10-29): when nothing from startIndex on is playable | |
| 407 | // there, the new queue is still shown (sendTrackAt publishes it). | |
| 408 | sendTrackAt(queueIndex, autoplay) | |
| 409 | Unit | |
| 410 | } | |
| 411 | ||
| 412 | // ---- Queue mutation ----------------------------------------------------- | |
| 413 | ||
| 414 | /** | |
| 415 | * Append [tracks] to the end of the queue. Per the Renderer contract, if | |
| 416 | * the queue was empty before this call, the tracks become the queue but | |
| 417 | * playback is NOT auto-started — caller follows up with explicit [play]. | |
| 418 | */ | |
| 419 | override suspend fun enqueue(tracks: List<DidlItem.Track>): Unit = withContext(queueCtx) { | |
| 420 | if (tracks.isEmpty()) return@withContext | |
| 421 | val wasEmpty = queue.isEmpty() | |
| 422 | val previousLast = queue.lastIndex | |
| 423 | queue.addAll(tracks) | |
| 424 | if (wasEmpty) { | |
| 425 | // Load (no autoplay) so position info / current-track-metadata | |
| 426 | // reflects the queued track for the UI. Caller will start playback. | |
| 427 | sendTrackAt(0, autoplay = false) | |
| 428 | } else { | |
| 429 | // If we just added a track that's now in the "next" slot (or, under | |
| 430 | // repeat ALL, changed what follows the last one), pre-load it. | |
| 431 | if (queueIndex == previousLast) preloadNextIfAny() | |
| 432 | publishQueueState() | |
| 433 | } | |
| 434 | Unit | |
| 435 | } | |
| 436 | ||
| 437 | /** | |
| 438 | * Insert [tracks] immediately after the currently-playing track. Per the | |
| 439 | * Renderer contract, if the queue was empty before this call, the tracks | |
| 440 | * become the queue AND start playing (this is the "play this album now" | |
| 441 | * affordance, as opposed to enqueue's "play after current"). | |
| 442 | */ | |
| 443 | override suspend fun playNext(tracks: List<DidlItem.Track>): Unit = withContext(queueCtx) { | |
| 444 | if (tracks.isEmpty()) return@withContext | |
| 445 | val wasEmpty = queue.isEmpty() | |
| 446 | val insertAt = (queueIndex + 1).coerceAtLeast(0).coerceAtMost(queue.size) | |
| 447 | queue.addAll(insertAt, tracks) | |
| 448 | if (wasEmpty) { | |
| 449 | sendTrackAt(0, autoplay = true) | |
| 450 | } else { | |
| 451 | // The track at insertAt is now the new "next". Pre-load it. | |
| 452 | preloadNextIfAny() | |
| 453 | publishQueueState() | |
| 454 | } | |
| 455 | Unit | |
| 456 | } | |
| 457 | ||
| 458 | /** Jump to a specific queue position. Plays immediately on success. */ | |
| 459 | override suspend fun skipToQueueItem(index: Int): Unit = withContext(queueCtx) { | |
| 460 | if (index < 0 || index >= queue.size) return@withContext | |
| 461 | sendTrackAt(index, autoplay = true) | |
| 462 | Unit | |
| 463 | } | |
| 464 | ||
| 465 | /** | |
| 466 | * Remove a track at [index] from the queue. Behavior matches the local | |
| 467 | * renderer's contract: | |
| 468 | * - Removing the currently-playing track advances to the next; if there | |
| 469 | * is no next, playback stops. | |
| 470 | * - Removing a track before the current adjusts [queueIndex] so the | |
| 471 | * same track stays "current". | |
| 472 | * - Removing a track after the current is invisible to playback but | |
| 473 | * may invalidate the pre-loaded next. | |
| 474 | */ | |
| 475 | override suspend fun removeFromQueue(index: Int): Unit = withContext(queueCtx) { | |
| 476 | if (index < 0 || index >= queue.size) return@withContext | |
| 477 | val wasCurrent = index == queueIndex | |
| 478 | val wasBeforeCurrent = index < queueIndex | |
| 479 | queue.removeAt(index) | |
| 480 | if (wasCurrent) { | |
| 481 | // After removeAt(queueIndex), what was queue[queueIndex+1] is now | |
| 482 | // at queue[queueIndex]. If queueIndex is still in range, that's | |
| 483 | // our new "current". If it's out of range, we removed the last | |
| 484 | // item in the queue — stop, don't loop back. | |
| 485 | if (queueIndex >= queue.size) { | |
| 486 | queueIndex = -1 | |
| 487 | stop() | |
| 488 | publishQueueState() | |
| 489 | } else { | |
| 490 | sendTrackAt(queueIndex, autoplay = _state.value.isPlaying) | |
| 491 | } | |
| 492 | } else { | |
| 493 | if (wasBeforeCurrent) queueIndex -= 1 | |
| 494 | // The pre-loaded next may now be wrong; re-issue. | |
| 495 | preloadNextIfAny() | |
| 496 | publishQueueState() | |
| 497 | } | |
| 498 | Unit | |
| 499 | } | |
| 500 | ||
| 501 | /** | |
| 502 | * Move a track from [from] to [to]. Adjusts [queueIndex] so the currently- | |
| 503 | * playing track stays "current" even if reorder shifts its position. | |
| 504 | */ | |
| 505 | override suspend fun moveInQueue(from: Int, to: Int): Unit = withContext(queueCtx) { | |
| 506 | if (from == to) return@withContext | |
| 507 | if (from !in queue.indices || to !in queue.indices) return@withContext | |
| 508 | val item = queue.removeAt(from) | |
| 509 | queue.add(to, item) | |
| 510 | // Track the queueIndex carefully through the shift. | |
| 511 | queueIndex = when { | |
| 512 | from == queueIndex -> to | |
| 513 | from < queueIndex && to >= queueIndex -> queueIndex - 1 | |
| 514 | from > queueIndex && to <= queueIndex -> queueIndex + 1 | |
| 515 | else -> queueIndex | |
| 516 | } | |
| 517 | preloadNextIfAny() | |
| 518 | publishQueueState() | |
| 519 | } | |
| 520 | ||
| 521 | /** Empty the queue and stop playback. */ | |
| 522 | override suspend fun clearQueue(): Unit = withContext(queueCtx) { | |
| 523 | queue.clear() | |
| 524 | queueIndex = -1 | |
| 525 | preShuffleOrder = null | |
| 526 | stop() | |
| 527 | // publishQueueState keeps the old `current` when the queue is empty | |
| 528 | // (it may be the device's own track); after a clear there is none. | |
| 529 | _state.update { it.copy(queue = emptyList(), queueIndex = -1, current = null) } | |
| 530 | } | |
| 531 | ||
| 532 | // ---- Queue helpers ------------------------------------------------------ | |
| 533 | ||
| 534 | /** Android parity (2026-10-29): true when the remote device can fetch [track]. | |
| 535 | * Local files (file://, plain paths) only exist on this computer. */ | |
| 536 | private fun isRemotePlayable(track: DidlItem.Track): Boolean = | |
| 537 | track.resourceUrl.startsWith("http://", ignoreCase = true) || | |
| 538 | track.resourceUrl.startsWith("https://", ignoreCase = true) | |
| 539 | ||
| 540 | /** | |
| 541 | * Send `queue[index]` to the remote via SetAVTransportURI, optionally start | |
| 542 | * it, then pre-load the next track for gapless transitions. Tracks the | |
| 543 | * device can't fetch are skipped, moving by [step] (Android, | |
| 544 | * 2026-10-29); when there is none, a forward move stops. Returns true when | |
| 545 | * a track was sent. Records the URI in [pendingExpectedUri] so its echo | |
| 546 | * isn't read as a change of track. | |
| 547 | */ | |
| 548 | private suspend fun sendTrackAt(index: Int, autoplay: Boolean, step: Int = 1): Boolean { | |
| 549 | lastTrackSentAt = System.currentTimeMillis() | |
| 550 | var playable = index | |
| 551 | while (playable in queue.indices && !isRemotePlayable(queue[playable])) playable += step | |
| 552 | val track = queue.getOrNull(playable) ?: run { | |
| 553 | log("No http(s) track from index $index on — local files can't play on ${target.friendlyName}") | |
| 554 | if (step > 0 && index in queue.indices) stop() | |
| 555 | publishQueueState() | |
| 556 | return false | |
| 557 | } | |
| 558 | if (playable != index) log("Skipped ${kotlin.math.abs(playable - index)} local-only track(s)") | |
| 559 | queueIndex = playable | |
| 560 | val didl = DidlLiteCodec.encodeTrack(track) | |
| 561 | previousRemoteUri = currentRemoteUri | |
| 562 | previousRemoteAlias = currentRemoteAlias | |
| 563 | currentRemoteUri = track.resourceUrl | |
| 564 | currentRemoteAlias = null | |
| 565 | pendingExpectedUri = track.resourceUrl | |
| 566 | lastSeenPosition = Duration.ZERO | |
| 567 | // The new track's own length, not the previous one's: the end-of-track | |
| 568 | // test compares against it. Events / the poller refine it. | |
| 569 | _state.update { | |
| 570 | it.copy( | |
| 571 | position = Duration.ZERO, | |
| 572 | duration = track.duration?.takeIf { d -> d > Duration.ZERO } ?: Duration.ZERO, | |
| 573 | ) | |
| 574 | } | |
| 575 | publishQueueState() | |
| 576 | invokeAvtAction( | |
| 577 | "SetAVTransportURI", | |
| 578 | mapOf( | |
| 579 | "CurrentURI" to track.resourceUrl, | |
| 580 | "CurrentURIMetaData" to didl, | |
| 581 | ) | |
| 582 | ) | |
| 583 | if (autoplay) play() | |
| 584 | publishQueueState() | |
| 585 | preloadNextIfAny() | |
| 586 | return true | |
| 587 | } | |
| 588 | ||
| 589 | /** | |
| 590 | * Tell the remote the URI of the next track in our queue via | |
| 591 | * SetNextAVTransportURI. Most renderers (Linn, BubbleUPnP, gmediarender, | |
| 592 | * Earhorn) honor this for gapless playback — they transition from the | |
| 593 | * current track to the next without an audible pause. | |
| 594 | * | |
| 595 | * Pass an empty URI to clear any previously-set next track. We do this | |
| 596 | * when our queue runs out so the renderer knows to stop rather than | |
| 597 | * loop back to a stale next-URI. | |
| 598 | * | |
| 599 | * Failures are logged and swallowed — pre-load is a nice-to-have, not | |
| 600 | * a correctness requirement. The auto-advance detection in | |
| 601 | * [handleCurrentTrackUriChange] handles the case where pre-load failed | |
| 602 | * by still recognizing our queue ownership and advancing locally. | |
| 603 | */ | |
| 604 | private suspend fun preloadNextIfAny() { | |
| 605 | // Repeat-aware, so repeat ALL wraps gaplessly. Repeat ONE is the | |
| 606 | // device's own REPEAT_ONE (see remotePlayMode); a track it can't fetch | |
| 607 | // is left to the STOPPED fallback, which skips it. | |
| 608 | val next = nextAutoIndex() | |
| 609 | ?.takeIf { _state.value.repeatMode != RepeatMode.ONE } | |
| 610 | ?.let(queue::getOrNull) | |
| 611 | ?.takeIf(::isRemotePlayable) | |
| 612 | if (next == null) { | |
| 613 | // Clear any previously-set next URI so the renderer doesn't | |
| 614 | // auto-advance to stale state when our current track ends. | |
| 615 | invokeAvtAction( | |
| 616 | "SetNextAVTransportURI", | |
| 617 | mapOf("NextURI" to "", "NextURIMetaData" to "") | |
| 618 | ) | |
| 619 | return | |
| 620 | } | |
| 621 | val didl = DidlLiteCodec.encodeTrack(next) | |
| 622 | invokeAvtAction( | |
| 623 | "SetNextAVTransportURI", | |
| 624 | mapOf( | |
| 625 | "NextURI" to next.resourceUrl, | |
| 626 | "NextURIMetaData" to didl, | |
| 627 | ) | |
| 628 | ) | |
| 629 | } | |
| 630 | ||
| 631 | /** | |
| 632 | * Snapshot the current queue state into [_state]. Called after every | |
| 633 | * queue mutation. Preserves any non-queue fields (transport state, | |
| 634 | * volume, position) that came from GENA events on the remote. | |
| 635 | */ | |
| 636 | private fun publishQueueState() { | |
| 637 | _state.value = _state.value.copy( | |
| 638 | queue = queue.toList(), | |
| 639 | queueIndex = queueIndex, | |
| 640 | current = queue.getOrNull(queueIndex) ?: _state.value.current, | |
| 641 | ) | |
| 642 | } | |
| 643 | ||
| 644 | /** | |
| 645 | * Called for every `CurrentTrackURI` the remote reports. Renderers resend it | |
| 646 | * with every LastChange (Earhorn's own renderer does), so most calls are | |
| 647 | * not a change at all. In order: | |
| 648 | * | |
| 649 | * 1. **Our own SetAVTransportURI echoed back** (matches [pendingExpectedUri]). | |
| 650 | * 2. **The current track again** (or what the device calls it), or a late | |
| 651 | * echo of the track we just replaced — nothing to do. These used to fall | |
| 652 | * through to case 5 and wipe the queue (Team 3 P1-8). | |
| 653 | * 3. **A rewritten form of the track we just sent** (a proxy or redirect), | |
| 654 | * reported within [SEND_GRACE_MS] of sending it: remember it as that | |
| 655 | * track's alias. | |
| 656 | * 4. **The device moved within our queue** — normally the gapless switch to | |
| 657 | * the pre-loaded next track ([nextAutoIndex]), or any other queue entry. | |
| 658 | * 5. **External hijack** (matches nothing in our queue) — another control | |
| 659 | * point sent SetAVTransportURI and changed what's playing. Our queue is | |
| 660 | * stale; let it go and show the device's own metadata. | |
| 661 | */ | |
| 662 | private suspend fun handleCurrentTrackUriChange(newUri: String) { | |
| 663 | if (newUri.isBlank() || newUri == "about:blank" || newUri == "NOT_IMPLEMENTED") return | |
| 664 | // Case 1: we set this URI ourselves. | |
| 665 | if (pendingExpectedUri == newUri) { | |
| 666 | pendingExpectedUri = null | |
| 667 | return | |
| 668 | } | |
| 669 | // Case 2: not a change. | |
| 670 | if (newUri == queue.getOrNull(queueIndex)?.resourceUrl || newUri == currentRemoteAlias) return | |
| 671 | if (newUri == previousRemoteUri || newUri == previousRemoteAlias) return | |
| 672 | // Case 3: our just-sent track under another name. | |
| 673 | if (pendingExpectedUri != null && | |
| 674 | System.currentTimeMillis() - lastTrackSentAt < SEND_GRACE_MS | |
| 675 | ) { | |
| 676 | log("Renderer reports a rewritten URI for the track just sent; treating it as that track") | |
| 677 | currentRemoteAlias = newUri | |
| 678 | pendingExpectedUri = null | |
| 679 | return | |
| 680 | } | |
| 681 | // Case 4: the device moved within our queue — the pre-loaded next | |
| 682 | // track first (gapless, repeat-aware), then any other entry. | |
| 683 | val nextIdx = nextAutoIndex() | |
| 684 | val idx = if (nextIdx != null && queue[nextIdx].resourceUrl == newUri) nextIdx | |
| 685 | else queue.indexOfFirst { it.resourceUrl == newUri } | |
| 686 | if (idx >= 0) { | |
| 687 | queueIndex = idx | |
| 688 | previousRemoteUri = currentRemoteUri | |
| 689 | previousRemoteAlias = currentRemoteAlias | |
| 690 | currentRemoteUri = newUri | |
| 691 | currentRemoteAlias = null | |
| 692 | pendingExpectedUri = null | |
| 693 | // A STOPPED blip right after the switch must not advance again. | |
| 694 | lastTrackSentAt = System.currentTimeMillis() | |
| 695 | lastSeenPosition = Duration.ZERO | |
| 696 | _state.update { | |
| 697 | it.copy( | |
| 698 | position = Duration.ZERO, | |
| 699 | duration = queue[idx].duration?.takeIf { d -> d > Duration.ZERO } ?: Duration.ZERO, | |
| 700 | ) | |
| 701 | } | |
| 702 | publishQueueState() | |
| 703 | preloadNextIfAny() | |
| 704 | return | |
| 705 | } | |
| 706 | // Case 5: external hijack. Release queue ownership. | |
| 707 | if (queue.isNotEmpty()) { | |
| 708 | log("CurrentTrackURI doesn't match our queue; another controller took over — releasing the queue") | |
| 709 | queue.clear() | |
| 710 | queueIndex = -1 | |
| 711 | preShuffleOrder = null | |
| 712 | currentRemoteUri = null | |
| 713 | currentRemoteAlias = null | |
| 714 | publishQueueState() | |
| 715 | } | |
| 716 | } | |
| 717 | ||
| 718 | // ---- Auto-advance (STOPPED fallback) ------------------------------------ | |
| 719 | ||
| 720 | /** Android parity (2026-10-29): where playback goes when OUR track ended — | |
| 721 | * repeat ONE replays it, repeat ALL wraps to the start, otherwise the | |
| 722 | * next track or nothing. */ | |
| 723 | private fun nextAutoIndex(): Int? = when { | |
| 724 | queue.isEmpty() || queueIndex !in queue.indices -> null | |
| 725 | _state.value.repeatMode == RepeatMode.ONE -> queueIndex | |
| 726 | queueIndex < queue.lastIndex -> queueIndex + 1 | |
| 727 | _state.value.repeatMode == RepeatMode.ALL -> 0 | |
| 728 | else -> null | |
| 729 | } | |
| 730 | ||
| 731 | /** | |
| 732 | * The device went STOPPED (seen by an event or by the poller). Android's | |
| 733 | * rule 2026-09-02: advance only when OUR track played to its end — the last | |
| 734 | * position seen is within 3 s of its length. A STOPPED right after we sent a | |
| 735 | * track is the device switching tracks and is ignored; any other stop (the | |
| 736 | * speaker's buttons, another app) is a stop, and nothing is sent. | |
| 737 | */ | |
| 738 | private suspend fun onRemoteStopped() { | |
| 739 | if (userRequestedStop) return | |
| 740 | if (System.currentTimeMillis() - lastTrackSentAt <= SEND_GRACE_MS) return | |
| 741 | val dur = _state.value.duration | |
| 742 | val reachedEnd = dur > Duration.ZERO && dur - lastSeenPosition <= END_OF_TRACK_SLACK | |
| 743 | val next = nextAutoIndex() | |
| 744 | if (!reachedEnd || next == null) { | |
| 745 | userRequestedStop = true // a stop from anywhere is a stop | |
| 746 | return | |
| 747 | } | |
| 748 | log("Our track ended — sending queue index $next") | |
| 749 | sendTrackAt(next, autoplay = true) | |
| 750 | } | |
| 751 | ||
| 752 | // ---- Repeat / shuffle --------------------------------------------------- | |
| 753 | ||
| 754 | override suspend fun setRepeatMode(mode: RepeatMode): Unit = withContext(queueCtx) { | |
| 755 | // Android parity (2026-10-29): keep the mode in our state (the UI cycles from | |
| 756 | // it; it was never set, so every press sent ALL). Repeat is applied by | |
| 757 | // our own advance logic (nextAutoIndex). See remotePlayMode for what | |
| 758 | // the device itself is told. | |
| 759 | _state.update { it.copy(repeatMode = mode) } | |
| 760 | invokeAvtAction("SetPlayMode", mapOf("NewPlayMode" to remotePlayMode())) | |
| 761 | // Repeat changes what "next" is at the end of the queue. | |
| 762 | if (queue.isNotEmpty()) preloadNextIfAny() | |
| 763 | Unit | |
| 764 | } | |
| 765 | ||
| 766 | /** | |
| 767 | * Keep the flag in our state so the toggle can be turned off again | |
| 768 | * (Android parity, 2026-10-29), and shuffle OUR queue: the tracks after the | |
| 769 | * current one, as the local renderer does. Turning it off restores the | |
| 770 | * earlier order, keeping tracks added meanwhile (at the end) and leaving out | |
| 771 | * tracks removed meanwhile. | |
| 772 | */ | |
| 773 | override suspend fun setShuffle(enabled: Boolean): Unit = withContext(queueCtx) { | |
| 774 | if (enabled != _state.value.shuffleEnabled && queue.isNotEmpty()) { | |
| 775 | if (enabled) { | |
| 776 | preShuffleOrder = queue.toList() | |
| 777 | shuffleTailAfterCurrent() | |
| 778 | } else { | |
| 779 | preShuffleOrder?.let { restoreOrder(it) } | |
| 780 | preShuffleOrder = null | |
| 781 | } | |
| 782 | } | |
| 783 | if (!enabled) preShuffleOrder = null | |
| 784 | _state.update { it.copy(shuffleEnabled = enabled) } | |
| 785 | invokeAvtAction("SetPlayMode", mapOf("NewPlayMode" to remotePlayMode())) | |
| 786 | if (queue.isNotEmpty()) { | |
| 787 | publishQueueState() | |
| 788 | preloadNextIfAny() | |
| 789 | } | |
| 790 | Unit | |
| 791 | } | |
| 792 | ||
| 793 | private fun shuffleTailAfterCurrent() { | |
| 794 | val from = (queueIndex + 1).coerceIn(0, queue.size) | |
| 795 | if (queue.size - from < 2) return | |
| 796 | val tail = queue.subList(from, queue.size).shuffled() | |
| 797 | while (queue.size > from) queue.removeAt(queue.lastIndex) | |
| 798 | queue.addAll(tail) | |
| 799 | } | |
| 800 | ||
| 801 | /** Put [queue] back in [order]'s sequence. Entries are matched as the same | |
| 802 | * objects, so duplicates and the current track keep their identity. */ | |
| 803 | private fun restoreOrder(order: List<DidlItem.Track>) { | |
| 804 | val current = queue.getOrNull(queueIndex) | |
| 805 | val remaining = queue.toMutableList() | |
| 806 | val restored = ArrayList<DidlItem.Track>(queue.size) | |
| 807 | for (t in order) { | |
| 808 | val i = remaining.indexOfFirst { it === t } | |
| 809 | if (i >= 0) restored += remaining.removeAt(i) | |
| 810 | } | |
| 811 | restored += remaining // added while shuffled | |
| 812 | queue.clear() | |
| 813 | queue.addAll(restored) | |
| 814 | if (current != null) queueIndex = queue.indexOfFirst { it === current }.coerceAtLeast(0) | |
| 815 | } | |
| 816 | ||
| 817 | /** Android parity (2026-10-29): the PlayMode the device itself gets. It only ever | |
| 818 | * holds ONE track (we send them one at a time), so REPEAT_ALL there just | |
| 819 | * loops that track and our queue never advances — repeat ALL is ours to | |
| 820 | * do, and the device stays NORMAL. REPEAT_ONE on the device is the same | |
| 821 | * thing we'd do. */ | |
| 822 | private fun remotePlayMode(): String { | |
| 823 | val s = _state.value | |
| 824 | return when { | |
| 825 | s.shuffleEnabled -> "SHUFFLE" | |
| 826 | s.repeatMode == RepeatMode.ONE -> "REPEAT_ONE" | |
| 827 | else -> "NORMAL" | |
| 828 | } | |
| 829 | } | |
| 830 | ||
| 831 | // ---- Initial state pulls ------------------------------------------------ | |
| 832 | ||
| 833 | private suspend fun pullInitialAvtState(device: RemoteDevice) { | |
| 834 | val info = invokeAvtAction(device, "GetTransportInfo", emptyMap()) | |
| 835 | val transportStateStr = info?.get("CurrentTransportState") ?: return | |
| 836 | val media = invokeAvtAction(device, "GetMediaInfo", emptyMap()) | |
| 837 | val mediaUri = media?.get("CurrentURI").orEmpty() | |
| 838 | val mediaDuration = media?.get("MediaDuration").orEmpty() | |
| 839 | val mediaMeta = media?.get("CurrentURIMetaData").orEmpty() | |
| 840 | val track = DidlLiteCodec.decodeFirstTrack(mediaMeta, mediaUri, requirePlayableUrl = false) | |
| 841 | _state.update { | |
| 842 | it.copy( | |
| 843 | // Our own queue (a load that raced ahead of this pull) wins. | |
| 844 | current = if (queue.isEmpty()) track ?: it.current else it.current, | |
| 845 | isPlaying = transportStateStr == "PLAYING", | |
| 846 | isStopped = transportStateStr == "STOPPED" || transportStateStr == "NO_MEDIA_PRESENT", | |
| 847 | isBuffering = transportStateStr == "TRANSITIONING", | |
| 848 | duration = if (queue.isEmpty()) parseHmsToDuration(mediaDuration) ?: Duration.ZERO else it.duration, | |
| 849 | ) | |
| 850 | } | |
| 851 | } | |
| 852 | ||
| 853 | private suspend fun pullInitialVolume(device: RemoteDevice) { | |
| 854 | val out = invokeRcAction(device, "GetVolume", mapOf("Channel" to "Master")) | |
| 855 | val pct = out?.get("CurrentVolume")?.toIntOrNull() ?: return | |
| 856 | _state.update { it.copy(volumePercent = pct.coerceIn(0, 100)) } | |
| 857 | } | |
| 858 | ||
| 859 | // ---- LastChange parsing ------------------------------------------------- | |
| 860 | ||
| 861 | private val avtParser by lazy { AVTransportLastChangeParser() } | |
| 862 | private val rcParser by lazy { RenderingControlLastChangeParser() } | |
| 863 | ||
| 864 | /** One AVTransport LastChange, on [queueCtx]: mirror it into [state], then | |
| 865 | * act on a reported track URI and on a fresh STOPPED. */ | |
| 866 | private suspend fun onAvtLastChange(xml: String) { | |
| 867 | val wasStopped = _state.value.isStopped | |
| 868 | val uri = parseAvtLastChange(xml) | |
| 869 | if (uri != null) handleCurrentTrackUriChange(uri) | |
| 870 | if (_state.value.isStopped && !wasStopped) onRemoteStopped() | |
| 871 | } | |
| 872 | ||
| 873 | /** Applies the event to [_state]; returns the CurrentTrackURI it carried. */ | |
| 874 | private fun parseAvtLastChange(xml: String): String? { | |
| 875 | var dispatchUri: String? = null | |
| 876 | runCatching { | |
| 877 | // LastChangeParser.parse(xml) returns an Event with one or more | |
| 878 | // InstanceID entries. Each InstanceID holds the EventedValue list | |
| 879 | // for that instance. We're single-instance (ID=0) and ignore the rest. | |
| 880 | val event = avtParser.parse(xml) ?: return@runCatching | |
| 881 | val instance = event.instanceIDs?.firstOrNull() ?: return@runCatching | |
| 882 | val values = instance.values ?: return@runCatching | |
| 883 | var cur = _state.value | |
| 884 | values.forEach { v: EventedValue<*> -> | |
| 885 | when (v) { | |
| 886 | is AVTransportVariable.TransportState -> { | |
| 887 | val s = v.value | |
| 888 | cur = cur.copy( | |
| 889 | isPlaying = s == org.jupnp.support.model.TransportState.PLAYING, | |
| 890 | isStopped = s == org.jupnp.support.model.TransportState.STOPPED || | |
| 891 | s == org.jupnp.support.model.TransportState.NO_MEDIA_PRESENT, | |
| 892 | isBuffering = s == org.jupnp.support.model.TransportState.TRANSITIONING, | |
| 893 | ) | |
| 894 | } | |
| 895 | is AVTransportVariable.CurrentTrackMetaData -> { | |
| 896 | // Only consume the remote's echoed metadata when we DON'T | |
| 897 | // own the queue. When we do, queue[queueIndex] is the | |
| 898 | // source of truth for `current` and the metadata echo | |
| 899 | // would just round-trip our own data back to us — or | |
| 900 | // worse, overwrite a richer local Track with a sparser | |
| 901 | // remote re-serialization. | |
| 902 | if (queue.isEmpty()) { | |
| 903 | val didl = v.value | |
| 904 | val uriHint = cur.current?.resourceUrl.orEmpty() | |
| 905 | DidlLiteCodec.decodeFirstTrack(didl, uriHint, requirePlayableUrl = false)?.let { | |
| 906 | cur = cur.copy(current = it) | |
| 907 | } | |
| 908 | } | |
| 909 | } | |
| 910 | is AVTransportVariable.CurrentTrackDuration -> { | |
| 911 | parseHmsToDuration(v.value)?.let { cur = cur.copy(duration = it) } | |
| 912 | } | |
| 913 | is AVTransportVariable.CurrentTrackURI -> { | |
| 914 | // Defer the work to handleCurrentTrackUriChange (suspend). | |
| 915 | // The whole point of this branch is queue auto-advance | |
| 916 | // detection: when the renderer transitions from current | |
| 917 | // to the pre-loaded next, this fires with the new URI. | |
| 918 | val u = v.value?.toString()?.takeIf { it.isNotBlank() } | |
| 919 | if (u != null) dispatchUri = u | |
| 920 | } | |
| 921 | // TransportStatus, AVTransportURI, AVTransportURIMetaData, | |
| 922 | // CurrentMediaDuration, NumberOfTracks, CurrentTrack — not | |
| 923 | // mirrored separately; CurrentTrackMetaData covers the | |
| 924 | // display surface and the queue covers the rest. | |
| 925 | else -> { /* ignore */ } | |
| 926 | } | |
| 927 | } | |
| 928 | _state.value = cur | |
| 929 | }.onFailure { | |
| 930 | log("parseAvtLastChange threw: ${it.message} — attempting regex fallback") | |
| 931 | dispatchUri = runCatching { parseAvtLastChangeFallback(xml) } | |
| 932 | .onFailure { fb -> log("regex fallback also failed: ${fb.message}") } | |
| 933 | .getOrNull() | |
| 934 | } | |
| 935 | return dispatchUri | |
| 936 | } | |
| 937 | ||
| 938 | /** | |
| 939 | * Defensive regex-based fallback for [parseAvtLastChange] when jUPnP's | |
| 940 | * [AVTransportLastChangeParser] throws — typically because the support | |
| 941 | * XSDs aren't on the classpath (a known issue on Android where the | |
| 942 | * `org/fourthline/cling/support/` resources are missing, and a | |
| 943 | * theoretical issue on desktop if a future jUPnP shading pass drops | |
| 944 | * them). Rather than lose state updates entirely on the failure | |
| 945 | * branch, we extract the variables we care about with a handful of | |
| 946 | * regexes and apply the same `_state` mutations the proper parser | |
| 947 | * would have. | |
| 948 | * | |
| 949 | * Patterns are deliberately namespace-tolerant — some renderers | |
| 950 | * prefix AVT variables (`<avt:TransportState val="…"/>`) and our | |
| 951 | * regex needs to match either form. We also accept the val attribute | |
| 952 | * in any position within the element rather than insisting it come | |
| 953 | * first. | |
| 954 | * | |
| 955 | * The CurrentTrackMetaData value arrives HTML-entity-encoded (it's | |
| 956 | * XML embedded in XML, escaped to keep parsers happy), so we run a | |
| 957 | * minimal entity decode before handing the DIDL-Lite payload to | |
| 958 | * [DidlLiteCodec.decodeFirstTrack]. The decode order matters: `&` | |
| 959 | * must be replaced last so we don't double-decode entities like | |
| 960 | * `&lt;` into `<` instead of `<`. | |
| 961 | */ | |
| 962 | private fun parseAvtLastChangeFallback(xml: String): String? { | |
| 963 | val transportStateRx = Regex( | |
| 964 | """<(?:[\w-]+:)?TransportState[^>]*\bval="([^"]+)"""" | |
| 965 | ) | |
| 966 | val durationRx = Regex( | |
| 967 | """<(?:[\w-]+:)?CurrentTrackDuration[^>]*\bval="([^"]+)"""" | |
| 968 | ) | |
| 969 | val metadataRx = Regex( | |
| 970 | """<(?:[\w-]+:)?CurrentTrackMetaData[^>]*\bval="([^"]*)"""" | |
| 971 | ) | |
| 972 | val uriRx = Regex( | |
| 973 | """<(?:[\w-]+:)?CurrentTrackURI[^>]*\bval="([^"]+)"""" | |
| 974 | ) | |
| 975 | ||
| 976 | var cur = _state.value | |
| 977 | var dispatchUri: String? = null | |
| 978 | ||
| 979 | transportStateRx.find(xml)?.groupValues?.getOrNull(1)?.let { s -> | |
| 980 | if (s == "PLAYING" && !cur.isPlaying) userRequestedStop = false // see the poller | |
| 981 | cur = cur.copy( | |
| 982 | isPlaying = s == "PLAYING", | |
| 983 | isStopped = s == "STOPPED" || s == "NO_MEDIA_PRESENT", | |
| 984 | isBuffering = s == "TRANSITIONING", | |
| 985 | ) | |
| 986 | } | |
| 987 | durationRx.find(xml)?.groupValues?.getOrNull(1)?.let { hms -> | |
| 988 | parseHmsToDuration(hms)?.let { cur = cur.copy(duration = it) } | |
| 989 | } | |
| 990 | if (queue.isEmpty()) { | |
| 991 | metadataRx.find(xml)?.groupValues?.getOrNull(1)?.let { encoded -> | |
| 992 | val didl = decodeHtmlEntities(encoded) | |
| 993 | val uriHint = cur.current?.resourceUrl.orEmpty() | |
| 994 | DidlLiteCodec.decodeFirstTrack(didl, uriHint, requirePlayableUrl = false)?.let { | |
| 995 | cur = cur.copy(current = it) | |
| 996 | } | |
| 997 | } | |
| 998 | } | |
| 999 | uriRx.find(xml)?.groupValues?.getOrNull(1) | |
| 1000 | ?.takeIf { it.isNotBlank() } | |
| 1001 | ?.let { dispatchUri = it } | |
| 1002 | ||
| 1003 | _state.value = cur | |
| 1004 | return dispatchUri | |
| 1005 | } | |
| 1006 | ||
| 1007 | /** | |
| 1008 | * Minimal HTML entity decoder for the five entities that show up in | |
| 1009 | * UPnP-escaped DIDL-Lite payloads. `&` must be replaced LAST or | |
| 1010 | * we'll double-decode (`&lt;` would become `<` instead of the | |
| 1011 | * intended `<`). | |
| 1012 | */ | |
| 1013 | private fun decodeHtmlEntities(s: String): String = | |
| 1014 | s.replace("<", "<") | |
| 1015 | .replace(">", ">") | |
| 1016 | .replace(""", "\"") | |
| 1017 | .replace("'", "'") | |
| 1018 | .replace("&", "&") | |
| 1019 | ||
| 1020 | private fun parseRcLastChange(xml: String) { | |
| 1021 | runCatching { | |
| 1022 | val event = rcParser.parse(xml) ?: return@runCatching | |
| 1023 | val instance = event.instanceIDs?.firstOrNull() ?: return@runCatching | |
| 1024 | val values = instance.values ?: return@runCatching | |
| 1025 | var cur = _state.value | |
| 1026 | values.forEach { v: EventedValue<*> -> | |
| 1027 | when (v) { | |
| 1028 | is RenderingControlVariable.Volume -> { | |
| 1029 | // ChannelVolume getters: channel + volume (Integer). | |
| 1030 | val cv = v.value | |
| 1031 | if (cv?.channel == org.jupnp.support.model.Channel.Master) { | |
| 1032 | cv.volume?.toInt()?.coerceIn(0, 100)?.let { vol -> | |
| 1033 | cur = cur.copy(volumePercent = vol) | |
| 1034 | } | |
| 1035 | } | |
| 1036 | } | |
| 1037 | is RenderingControlVariable.Mute -> { | |
| 1038 | // We don't track mute in PlaybackState; volume = 0 conveys it. | |
| 1039 | } | |
| 1040 | else -> { /* Loudness, PresetNameList, etc. — ignored */ } | |
| 1041 | } | |
| 1042 | } | |
| 1043 | _state.value = cur | |
| 1044 | }.onFailure { log("parseRcLastChange threw: ${it.message}") } | |
| 1045 | } | |
| 1046 | ||
| 1047 | // ---- Poller ------------------------------------------------------------- | |
| 1048 | ||
| 1049 | /** Poll GetTransportInfo (1 s while playing, 10 s otherwise) and | |
| 1050 | * GetPositionInfo while playing. This is also the auto-advance path when | |
| 1051 | * GENA events never arrive (Android's poller, Android parity | |
| 1052 | * 2026-09-02: still notices someone pressing Play on the speaker itself, | |
| 1053 | * without sixty requests a minute for nothing). */ | |
| 1054 | private fun startPositionPoller() { | |
| 1055 | positionPollerJob?.cancel() | |
| 1056 | positionPollerJob = scope.launch(queueCtx) { | |
| 1057 | while (true) { | |
| 1058 | val device = findRemoteDevice() | |
| 1059 | if (device != null) { | |
| 1060 | catching("GetTransportInfo") { | |
| 1061 | val ts = invokeAvtAction(device, "GetTransportInfo", emptyMap()) | |
| 1062 | ?.get("CurrentTransportState") | |
| 1063 | if (!ts.isNullOrBlank()) { | |
| 1064 | val wasStopped = _state.value.isStopped | |
| 1065 | val nowStopped = ts == "STOPPED" || ts == "NO_MEDIA_PRESENT" | |
| 1066 | // Playing again after a stop from anywhere (e.g. Play | |
| 1067 | // on the speaker itself): the queue should carry on | |
| 1068 | // at the end of this track, so re-arm auto-advance. | |
| 1069 | if (ts == "PLAYING" && !_state.value.isPlaying) userRequestedStop = false | |
| 1070 | _state.update { | |
| 1071 | it.copy( | |
| 1072 | isPlaying = ts == "PLAYING", | |
| 1073 | isStopped = nowStopped, | |
| 1074 | isBuffering = ts == "TRANSITIONING", | |
| 1075 | ) | |
| 1076 | } | |
| 1077 | if (nowStopped && !wasStopped) onRemoteStopped() | |
| 1078 | } | |
| 1079 | } | |
| 1080 | // Position while playing; remembered for the end-of-track test. | |
| 1081 | if (_state.value.isPlaying) { | |
| 1082 | catching("GetPositionInfo") { | |
| 1083 | val out = invokeAvtAction(device, "GetPositionInfo", emptyMap()) | |
| 1084 | // A renderer that honours SetNextAVTransportURI but | |
| 1085 | // sends no events moves to the pre-loaded track with | |
| 1086 | // no STOPPED we'd see. Its TrackURI is the only sign; | |
| 1087 | // without this the UI keeps the old track and the | |
| 1088 | // end-of-track test runs against the wrong length. | |
| 1089 | // Only moves WITHIN our queue are acted on here; a URI | |
| 1090 | // that matches nothing could be the device's rewritten | |
| 1091 | // name for our track, and treating that as a takeover | |
| 1092 | // from a poll would drop the queue. Takeovers are left | |
| 1093 | // to the evented path. | |
| 1094 | out?.get("TrackURI") | |
| 1095 | ?.takeIf { uri -> uri.isNotBlank() && queue.any { it.resourceUrl == uri } } | |
| 1096 | ?.let { uri -> handleCurrentTrackUriChange(uri) } | |
| 1097 | parseHmsToDuration(out?.get("RelTime"))?.let { p -> | |
| 1098 | _state.update { it.copy(position = p) } | |
| 1099 | lastSeenPosition = p | |
| 1100 | } | |
| 1101 | if (_state.value.duration == Duration.ZERO) { | |
| 1102 | parseHmsToDuration(out?.get("TrackDuration"))?.let { d -> | |
| 1103 | _state.update { it.copy(duration = d) } | |
| 1104 | } | |
| 1105 | } | |
| 1106 | } | |
| 1107 | } | |
| 1108 | } | |
| 1109 | delay(if (_state.value.isPlaying) 1_000 else 10_000) | |
| 1110 | } | |
| 1111 | } | |
| 1112 | } | |
| 1113 | ||
| 1114 | // ---- jUPnP action plumbing ---------------------------------------------- | |
| 1115 | ||
| 1116 | /** Invoke a named action on the remote device's AVTransport service. Returns the | |
| 1117 | * output args by name, or null on failure. Suspends until the action completes. */ | |
| 1118 | private suspend fun invokeAvtAction( | |
| 1119 | actionName: String, | |
| 1120 | args: Map<String, String> = emptyMap(), | |
| 1121 | ): Map<String, String>? { | |
| 1122 | val device = findRemoteDevice() ?: return null | |
| 1123 | return invokeAvtAction(device, actionName, args) | |
| 1124 | } | |
| 1125 | ||
| 1126 | private suspend fun invokeAvtAction( | |
| 1127 | device: RemoteDevice, | |
| 1128 | actionName: String, | |
| 1129 | args: Map<String, String>, | |
| 1130 | ): Map<String, String>? { | |
| 1131 | val service = device.findAvtService() ?: return null | |
| 1132 | return invokeOnService(service, actionName, args) | |
| 1133 | } | |
| 1134 | ||
| 1135 | private suspend fun invokeRcAction( | |
| 1136 | actionName: String, | |
| 1137 | args: Map<String, String> = emptyMap(), | |
| 1138 | ): Map<String, String>? { | |
| 1139 | val device = findRemoteDevice() ?: return null | |
| 1140 | return invokeRcAction(device, actionName, args) | |
| 1141 | } | |
| 1142 | ||
| 1143 | private suspend fun invokeRcAction( | |
| 1144 | device: RemoteDevice, | |
| 1145 | actionName: String, | |
| 1146 | args: Map<String, String>, | |
| 1147 | ): Map<String, String>? { | |
| 1148 | val service = device.findRcService() ?: return null | |
| 1149 | return invokeOnService(service, actionName, args) | |
| 1150 | } | |
| 1151 | ||
| 1152 | /** Core suspend-wrap of jUPnP's callback-based execute(). Always includes | |
| 1153 | * `InstanceID=0` as the first argument since Earhorn is single-instance. */ | |
| 1154 | private suspend fun invokeOnService( | |
| 1155 | service: RemoteService, | |
| 1156 | actionName: String, | |
| 1157 | args: Map<String, String>, | |
| 1158 | ): Map<String, String>? { | |
| 1159 | val action = service.getAction(actionName) ?: run { | |
| 1160 | log("Service has no '$actionName' action") | |
| 1161 | return null | |
| 1162 | } | |
| 1163 | val controlPoint = holder.controlPoint ?: return null | |
| 1164 | ||
| 1165 | return suspendCancellableCoroutine { cont -> | |
| 1166 | val invocation = ActionInvocation(action) | |
| 1167 | // InstanceID is the conventional first arg for both AVT and RC. | |
| 1168 | // Renderers that don't support multi-instance ignore non-zero IDs. | |
| 1169 | runCatching { invocation.setInput("InstanceID", "0") } | |
| 1170 | args.forEach { (k, v) -> | |
| 1171 | runCatching { invocation.setInput(k, v) } | |
| 1172 | } | |
| 1173 | ||
| 1174 | val callback = object : ActionCallback(invocation) { | |
| 1175 | override fun success(inv: ActionInvocation<*>?) { | |
| 1176 | val outputs = inv?.toOutputMap().orEmpty() | |
| 1177 | if (cont.isActive) cont.resume(outputs) | |
| 1178 | } | |
| 1179 | override fun failure( | |
| 1180 | inv: ActionInvocation<*>?, | |
| 1181 | operation: UpnpResponse?, | |
| 1182 | defaultMsg: String?, | |
| 1183 | ) { | |
| 1184 | val cause = inv?.failure as? ActionException ?: ActionException( | |
| 1185 | org.jupnp.model.types.ErrorCode.ACTION_FAILED, | |
| 1186 | defaultMsg ?: "Unknown error" | |
| 1187 | ) | |
| 1188 | log("Action $actionName failed: ${cause.message}") | |
| 1189 | if (cont.isActive) cont.resume(null) | |
| 1190 | } | |
| 1191 | } | |
| 1192 | controlPoint.execute(callback) | |
| 1193 | } | |
| 1194 | } | |
| 1195 | ||
| 1196 | /** Convert an [ActionInvocation]'s output arguments into a name→string-value map. | |
| 1197 | * Function (not property) to avoid colliding with whatever name jUPnP may already | |
| 1198 | * expose on the class — extension properties don't override member properties, | |
| 1199 | * and the compiler errors get confusing fast. */ | |
| 1200 | private fun ActionInvocation<*>.toOutputMap(): Map<String, String> = | |
| 1201 | output.orEmpty().associate { it.argument.name to (it.value?.toString().orEmpty()) } | |
| 1202 | ||
| 1203 | // ---- Helpers ------------------------------------------------------------ | |
| 1204 | ||
| 1205 | private fun findRemoteDevice(): RemoteDevice? = holder.findDevice(target.udn) | |
| 1206 | ||
| 1207 | private fun RemoteDevice.findAvtService(): RemoteService? = | |
| 1208 | findService(UDAServiceType("AVTransport", 1)) as? RemoteService | |
| 1209 | ||
| 1210 | private fun RemoteDevice.findRcService(): RemoteService? = | |
| 1211 | findService(UDAServiceType("RenderingControl", 1)) as? RemoteService | |
| 1212 | ||
| 1213 | private fun formatDurationAsUpnpClock(duration: Duration): String { | |
| 1214 | val totalMs = duration.inWholeMilliseconds.coerceAtLeast(0) | |
| 1215 | val ms = totalMs % 1000 | |
| 1216 | val total = totalMs / 1000 | |
| 1217 | val h = total / 3600 | |
| 1218 | val m = (total % 3600) / 60 | |
| 1219 | val s = total % 60 | |
| 1220 | return "%d:%02d:%02d.%03d".format(h, m, s, ms) | |
| 1221 | } | |
| 1222 | ||
| 1223 | private fun parseHmsToDuration(clock: String?): Duration? { | |
| 1224 | if (clock.isNullOrBlank() || clock == "NOT_IMPLEMENTED") return null | |
| 1225 | val parts = clock.split(":") | |
| 1226 | if (parts.size != 3) return null | |
| 1227 | val h = parts[0].toLongOrNull() ?: return null | |
| 1228 | val m = parts[1].toLongOrNull() ?: return null | |
| 1229 | val s = parts[2].toDoubleOrNull() ?: return null | |
| 1230 | val ms = ((h * 3600 + m * 60) * 1000 + (s * 1000).toLong()) | |
| 1231 | return ms.milliseconds | |
| 1232 | } | |
| 1233 | ||
| 1234 | /** try/catch for the network steps above: logs and returns null on | |
| 1235 | * failure, but lets cancellation through (runCatching would swallow it). */ | |
| 1236 | private inline fun <T> catching(what: String, block: () -> T): T? = try { | |
| 1237 | block() | |
| 1238 | } catch (e: CancellationException) { | |
| 1239 | throw e | |
| 1240 | } catch (e: Exception) { | |
| 1241 | log("$what failed: ${e.message}") | |
| 1242 | null | |
| 1243 | } | |
| 1244 | ||
| 1245 | private fun log(msg: String) { | |
| 1246 | println("[RemoteUpnpRenderer/${target.friendlyName}] $msg") | |
| 1247 | } | |
| 1248 | ||
| 1249 | private companion object { | |
| 1250 | /** After sending a track: no STOPPED counts as "ended", and a different | |
| 1251 | * URI counts as the device's name for it (Android parity, 2026-09-02). */ | |
| 1252 | const val SEND_GRACE_MS = 5_000L | |
| 1253 | ||
| 1254 | /** "Reached the end": last position seen within this of the length. */ | |
| 1255 | val END_OF_TRACK_SLACK = 3.seconds | |
| 1256 | } | |
| 1257 | } |


