EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

RemoteUpnpRenderer.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp.renderer
2
3import dev.stave.domain.model.DidlItem
4import dev.stave.domain.model.RemoteRenderer
5import dev.stave.domain.renderer.PlaybackState
6import dev.stave.domain.renderer.RepeatMode
7import dev.stave.domain.renderer.Renderer
8import dev.stave.upnp.UpnpServiceHolder
9import kotlinx.coroutines.CancellationException
10import kotlinx.coroutines.CoroutineScope
11import kotlinx.coroutines.Dispatchers
12import kotlinx.coroutines.Job
13import kotlinx.coroutines.SupervisorJob
14import kotlinx.coroutines.cancel
15import kotlinx.coroutines.delay
16import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.StateFlow
18import kotlinx.coroutines.flow.asStateFlow
19import kotlinx.coroutines.flow.update
20import kotlinx.coroutines.launch
21import kotlinx.coroutines.suspendCancellableCoroutine
22import kotlinx.coroutines.withContext
23import org.jupnp.controlpoint.ActionCallback
24import org.jupnp.controlpoint.SubscriptionCallback
25import org.jupnp.model.action.ActionException
26import org.jupnp.model.action.ActionInvocation
27import org.jupnp.model.gena.CancelReason
28import org.jupnp.model.gena.GENASubscription
29import org.jupnp.model.message.UpnpResponse
30import org.jupnp.model.meta.RemoteDevice
31import org.jupnp.model.meta.RemoteService
32import org.jupnp.model.types.UDAServiceType
33import org.jupnp.support.avtransport.lastchange.AVTransportLastChangeParser
34import org.jupnp.support.avtransport.lastchange.AVTransportVariable
35import org.jupnp.support.lastchange.Event
36import org.jupnp.support.lastchange.EventedValue
37import org.jupnp.support.renderingcontrol.lastchange.RenderingControlLastChangeParser
38import org.jupnp.support.renderingcontrol.lastchange.RenderingControlVariable
39import kotlin.coroutines.resume
40import kotlin.time.Duration
41import kotlin.time.Duration.Companion.milliseconds
42import 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 */
94class 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: `&amp;`
959 * must be replaced last so we don't double-decode entities like
960 * `&amp;lt;` into `<` instead of `&lt;`.
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. `&amp;` must be replaced LAST or
1010 * we'll double-decode (`&amp;lt;` would become `<` instead of the
1011 * intended `&lt;`).
1012 */
1013 private fun decodeHtmlEntities(s: String): String =
1014 s.replace("&lt;", "<")
1015 .replace("&gt;", ">")
1016 .replace("&quot;", "\"")
1017 .replace("&apos;", "'")
1018 .replace("&amp;", "&")
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}