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.CoroutineScope
10import kotlinx.coroutines.Dispatchers
11import kotlinx.coroutines.Job
12import kotlinx.coroutines.SupervisorJob
13import kotlinx.coroutines.cancel
14import kotlinx.coroutines.delay
15import kotlinx.coroutines.flow.MutableStateFlow
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.update
19import kotlinx.coroutines.launch
20import kotlinx.coroutines.suspendCancellableCoroutine
21import org.jupnp.controlpoint.ActionCallback
22import org.jupnp.controlpoint.SubscriptionCallback
23import org.jupnp.model.action.ActionException
24import org.jupnp.model.action.ActionInvocation
25import org.jupnp.model.gena.CancelReason
26import org.jupnp.model.gena.GENASubscription
27import org.jupnp.model.message.UpnpResponse
28import org.jupnp.model.meta.RemoteDevice
29import org.jupnp.model.meta.RemoteService
30import org.jupnp.model.types.UDAServiceType
31import org.jupnp.support.avtransport.lastchange.AVTransportLastChangeParser
32import org.jupnp.support.avtransport.lastchange.AVTransportVariable
33import org.jupnp.support.lastchange.Event
34import org.jupnp.support.lastchange.EventedValue
35import org.jupnp.support.renderingcontrol.lastchange.RenderingControlLastChangeParser
36import org.jupnp.support.renderingcontrol.lastchange.RenderingControlVariable
37import kotlin.coroutines.resume
38import kotlin.time.Duration
39import kotlin.time.Duration.Companion.milliseconds
40import kotlin.time.Duration.Companion.seconds
41
42/**
43 * A [Renderer] implementation that drives a REMOTE UPnP MediaRenderer device discovered on
44 * the LAN. The remote device is the actual audio source (another Earhorn instance, a Linn
45 * streamer, gmediarender, BubbleUPnP receiver, etc.) — this class is the control-point
46 * adapter that makes a remote renderer look like a local one to the rest of the app.
47 *
48 * Two halves of behavior:
49 *
50 * 1. **Outgoing actions.** [play], [pause], [stop], [seekTo], [next], [previous],
51 * [setVolume] each translate to a UPnP action invoked on the remote device's
52 * AVTransport or RenderingControl service. We use jUPnP's `ControlPoint.execute`
53 * with [ActionCallback], wrapped in [suspendCancellableCoroutine] so callers can
54 * `suspend` on completion the same way they do with the local renderer.
55 *
56 * 2. **Incoming state.** We open GENA subscriptions to the remote device's AVTransport
57 * and RenderingControl `LastChange` evented state variables. Push notifications
58 * from the renderer arrive as [eventReceived] callbacks; we parse the LastChange
59 * payload with the service-specific parser and update [state] accordingly. Plus
60 * a fallback poller for position (which UPnP doesn't event — it's pull-only via
61 * `GetPositionInfo`).
62 *
63 * **Queue.** This class keeps the queue locally and feeds the renderer one
64 * track at a time via SetAVTransportURI ([load], [enqueue], [playNext],
65 * [skipToQueueItem], [removeFromQueue], [moveInQueue], [clearQueue]).
66 * Maintainer, 2026-10-29: moveInQueue/clearQueue implemented; tracks the device
67 * can't fetch (local files) are skipped.
68 * (An earlier version of this header described all of these as no-ops.)
69 *
70 * **Lifecycle.**
71 *
72 * The coordinator (next iteration) constructs a fresh [RemoteUpnpRenderer] each time
73 * the user picks a different remote renderer, calls [attach] to open subscriptions,
74 * and calls [detach] when switching away or shutting down. [detach] cancels the
75 * subscription and stops the position poller cleanly so we don't leak GENA listeners
76 * on the remote device — even Earhorn-to-Earhorn subscriptions don't auto-clean up
77 * if the controller process dies abruptly, so detaching is hygienic for graceful
78 * shutdown.
79 */
80class RemoteUpnpRenderer(
81 private val target: RemoteRenderer,
82 private val holder: UpnpServiceHolder,
83) : Renderer {
84
85 /** UDN of the remote renderer this instance is bound to. Public so the
86 * coordinator can match an attached instance against the user's selection
87 * without holding a separate lookup table. Stable for the life of the
88 * instance — to drive a different renderer, build a new instance. */
89 val targetUdn: String get() = target.udn
90
91 /** Display name of the remote — likewise public for the same reason. */
92 val targetFriendlyName: String get() = target.friendlyName
93
94 private val _state = MutableStateFlow(PlaybackState())
95 override val state: StateFlow<PlaybackState> = _state.asStateFlow()
96
97 // ---- Local queue driver (phone-side queue for remote renderer) ----------
98 private var localQueue: List<DidlItem.Track> = emptyList()
99 private var localQueueIndex: Int = 0
100 private var userRequestedStop: Boolean = false
101 private var lastTrackSentAt: Long = 0L
102
103 private fun updateQueueInState() {
104 _state.value = _state.value.copy(
105 queue = localQueue,
106 queueIndex = localQueueIndex,
107 current = localQueue.getOrNull(localQueueIndex) ?: _state.value.current,
108 )
109 }
110
111 /** Maintainer, 2026-10-29: true when the remote device can fetch [track].
112 * Local files (content://, file://) only exist on this device. */
113 private fun isRemotePlayable(track: DidlItem.Track): Boolean =
114 track.resourceUrl.startsWith("http://", ignoreCase = true) ||
115 track.resourceUrl.startsWith("https://", ignoreCase = true)
116
117 /** Maintainer, 2026-10-29: skips tracks the remote can't fetch (local files
118 * were sent as content:// URIs and failed silently), moving by [step]
119 * from [index] to the next playable one; stops (step > 0) when there is
120 * none. Returns true when a track was sent. */
121 private suspend fun sendTrackAtIndex(index: Int, autoplay: Boolean = true, step: Int = 1): Boolean {
122 lastTrackSentAt = System.currentTimeMillis()
123 if (index !in localQueue.indices) return false
124 var playable = index
125 while (playable in localQueue.indices && !isRemotePlayable(localQueue[playable])) playable += step
126 val track = localQueue.getOrNull(playable) ?: run {
127 log("No http(s) track from index $index on — local files can't play on ${target.friendlyName}")
128 if (step > 0) stop()
129 return false
130 }
131 if (playable != index) log("Skipped ${kotlin.math.abs(playable - index)} local-only track(s)")
132 localQueueIndex = playable
133 val didl = DidlLiteCodec.encodeTrack(track)
134 invokeAvtAction(
135 "SetAVTransportURI",
136 mapOf(
137 "CurrentURI" to track.resourceUrl,
138 "CurrentURIMetaData" to didl,
139 )
140 )
141 updateQueueInState()
142 if (autoplay) play()
143 return true
144 }
145
146 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
147
148 /** Subscription tickets we need to cancel on detach. jUPnP's
149 * [SubscriptionCallback.end] sends the UNSUBSCRIBE request to the renderer. */
150 private var avtSubscription: SubscriptionCallback? = null
151 private var rcSubscription: SubscriptionCallback? = null
152
153 /** Position poller. UPnP doesn't event the current track position — it's only
154 * queryable via [GetPositionInfo]. Polls transport state once a second while
155 * playing and every ten seconds otherwise; position only while playing. */
156 private var positionPollerJob: Job? = null
157
158 // ---- Lifecycle ----------------------------------------------------------
159
160 /**
161 * Open the GENA subscriptions and prime [state] with an initial snapshot. Idempotent;
162 * calling twice is a no-op. Doesn't throw — failures are logged and the renderer
163 * comes up in a degraded "connected but no events" mode that callers can still issue
164 * actions against (each action runs an independent invocation).
165 */
166 fun attach() {
167 if (avtSubscription != null) return // already attached
168 log("attach() target=${target.friendlyName} udn=${target.udn}")
169
170 val device = findRemoteDevice()
171 if (device == null) {
172 log("Device with UDN ${target.udn} is not in the registry — cannot attach")
173 return
174 }
175
176 // Initial best-effort state pull via individual GetXxx actions. The renderer
177 // will then keep us in sync via GENA events. If GENA fails (some bargain-bin
178 // renderers don't fire events even though they accept the subscription), the
179 // initial snapshot is at least useful and the action surface still works.
180 scope.launch {
181 runCatching { pullInitialAvtState(device) }
182 .onFailure { log("pullInitialAvtState failed: ${it.message}") }
183 runCatching { pullInitialVolume(device) }
184 .onFailure { log("pullInitialVolume failed: ${it.message}") }
185 }
186
187 device.findAvtService()?.let { svc ->
188 val cb = object : SubscriptionCallback(svc) {
189 override fun established(s: GENASubscription<*>) {
190 log("AVT subscription established")
191 }
192 override fun ended(
193 s: GENASubscription<*>,
194 reason: CancelReason?,
195 resp: UpnpResponse?,
196 ) {
197 log("AVT subscription ended reason=$reason rc=${resp?.statusCode}")
198 }
199 override fun eventReceived(s: GENASubscription<*>) {
200 val raw = s.currentValues["LastChange"]?.value as? String ?: return
201 parseAvtLastChange(raw)
202 }
203 override fun eventsMissed(s: GENASubscription<*>, count: Int) {
204 log("AVT subscription missed $count event(s) — state may be stale")
205 }
206 override fun failed(
207 s: GENASubscription<*>?,
208 resp: UpnpResponse?,
209 ex: Exception?,
210 defaultMsg: String,
211 ) {
212 log("AVT subscription failed: $defaultMsg ${ex?.message ?: ""}")
213 }
214 }
215 holder.controlPoint?.execute(cb)
216 avtSubscription = cb
217 } ?: log("Remote device has no AVTransport service — degraded mode")
218
219 device.findRcService()?.let { svc ->
220 val cb = object : SubscriptionCallback(svc) {
221 override fun established(s: GENASubscription<*>) {}
222 override fun ended(
223 s: GENASubscription<*>,
224 reason: CancelReason?,
225 resp: UpnpResponse?,
226 ) {}
227 override fun eventReceived(s: GENASubscription<*>) {
228 val raw = s.currentValues["LastChange"]?.value as? String ?: return
229 parseRcLastChange(raw)
230 }
231 override fun eventsMissed(s: GENASubscription<*>, count: Int) {}
232 override fun failed(
233 s: GENASubscription<*>?,
234 resp: UpnpResponse?,
235 ex: Exception?,
236 defaultMsg: String,
237 ) {
238 log("RC subscription failed: $defaultMsg")
239 }
240 }
241 holder.controlPoint?.execute(cb)
242 rcSubscription = cb
243 } ?: log("Remote device has no RenderingControl service — volume disabled")
244
245 startPositionPoller()
246 }
247
248 /** Cancel GENA subscriptions, stop pollers, and release the coroutine scope.
249 * Safe to call multiple times; subsequent calls are no-ops. */
250 fun detach() {
251 log("detach() target=${target.friendlyName}")
252 runCatching { avtSubscription?.end() }
253 runCatching { rcSubscription?.end() }
254 avtSubscription = null
255 rcSubscription = null
256 positionPollerJob?.cancel()
257 positionPollerJob = null
258 scope.cancel()
259 }
260
261 // ---- Outgoing actions (Renderer interface) ------------------------------
262
263 override suspend fun play() {
264 userRequestedStop = false
265 invokeAvtAction("Play", mapOf("Speed" to "1"))
266 }
267
268 override suspend fun pause() {
269 invokeAvtAction("Pause")
270 }
271
272 override suspend fun stop() {
273 userRequestedStop = true
274 // Clear gapless prefetch so the remote doesn't auto-advance after Stop.
275 runCatching {
276 invokeAvtAction("SetNextAVTransportURI", mapOf(
277 "NextURI" to "",
278 "NextURIMetaData" to "",
279 ))
280 }
281 invokeAvtAction("Stop")
282 }
283
284 override suspend fun seekTo(position: Duration) {
285 invokeAvtAction(
286 "Seek",
287 mapOf(
288 "Unit" to "REL_TIME",
289 "Target" to formatDurationAsUpnpClock(position),
290 )
291 )
292 }
293
294 override suspend fun next() {
295 if (localQueueIndex < localQueue.size - 1) {
296 sendTrackAtIndex(localQueueIndex + 1, autoplay = true)
297 } else {
298 invokeAvtAction("Next")
299 }
300 }
301
302 override suspend fun previous() {
303 if (localQueueIndex > 0) {
304 // Maintainer, 2026-10-29: step back past local-only tracks.
305 sendTrackAtIndex(localQueueIndex - 1, autoplay = true, step = -1)
306 } else {
307 invokeAvtAction("Previous")
308 }
309 }
310
311 override suspend fun setVolume(volumePercent: Int) {
312 invokeRcAction(
313 "SetVolume",
314 mapOf(
315 "Channel" to "Master",
316 "DesiredVolume" to volumePercent.coerceIn(0, 100).toString(),
317 )
318 )
319 }
320
321 /**
322 * Loads a queue of tracks. The phone keeps the full queue locally and feeds
323 * tracks one at a time to the remote renderer via SetAVTransportURI. Auto-
324 * advance happens when the transport state goes STOPPED after a natural
325 * track end (not user-initiated stop).
326 */
327 override suspend fun load(
328 tracks: List<DidlItem.Track>,
329 startIndex: Int,
330 autoplay: Boolean,
331 ) {
332 localQueue = tracks.toList()
333 userRequestedStop = false
334 log("load() ${tracks.size} tracks, startIndex=$startIndex, autoplay=$autoplay")
335 // Maintainer, 2026-10-29: when nothing from startIndex on is playable here
336 // (all local files), still publish the new queue so the screen isn't stale.
337 if (!sendTrackAtIndex(startIndex, autoplay)) updateQueueInState()
338 }
339
340 // ---- Queue mutation (no-op pending Phase 2 part C: queue driver) --------
341
342 override suspend fun enqueue(tracks: List<DidlItem.Track>) {
343 localQueue = localQueue + tracks
344 updateQueueInState()
345 log("enqueue() added ${tracks.size} tracks, queue now ${localQueue.size}")
346 }
347 override suspend fun playNext(tracks: List<DidlItem.Track>) {
348 val insertAt = localQueueIndex + 1
349 localQueue = localQueue.toMutableList().apply { addAll(insertAt.coerceAtMost(size), tracks) }
350 updateQueueInState()
351 log("playNext() inserted ${tracks.size} tracks at $insertAt")
352 }
353 override suspend fun skipToQueueItem(index: Int) {
354 if (index in localQueue.indices) {
355 sendTrackAtIndex(index, autoplay = true)
356 }
357 }
358 override suspend fun removeFromQueue(index: Int) {
359 if (index !in localQueue.indices) return
360 if (index == localQueueIndex) {
361 // Maintainer, 2026-10-29: removing the CURRENT track was ignored.
362 // Move on to the next (playable) track first, keeping play/pause,
363 // or stop when there is none; then remove it.
364 val wasPlaying = _state.value.isPlaying
365 if (index < localQueue.lastIndex) {
366 // Paused: the next track is loaded but waits for Play, which
367 // must not read as "our track ended" (see shouldAutoAdvance).
368 if (sendTrackAtIndex(index + 1, autoplay = wasPlaying) && !wasPlaying) userRequestedStop = true
369 } else {
370 stop()
371 }
372 }
373 localQueue = localQueue.toMutableList().apply { removeAt(index) }
374 if (index < localQueueIndex) localQueueIndex--
375 if (localQueue.isEmpty()) {
376 localQueueIndex = 0
377 _state.update { it.copy(queue = emptyList(), current = null, queueIndex = -1) }
378 } else {
379 localQueueIndex = localQueueIndex.coerceIn(0, localQueue.lastIndex)
380 updateQueueInState()
381 }
382 }
383 override suspend fun moveInQueue(from: Int, to: Int) {
384 // Maintainer, 2026-10-29: was a no-op. Reorder the phone-side queue;
385 // the current track keeps playing and keeps being "current".
386 if (from !in localQueue.indices || to !in localQueue.indices || from == to) return
387 localQueue = localQueue.toMutableList().apply { add(to, removeAt(from)) }
388 localQueueIndex = when {
389 localQueueIndex == from -> to
390 from < localQueueIndex && to >= localQueueIndex -> localQueueIndex - 1
391 from > localQueueIndex && to <= localQueueIndex -> localQueueIndex + 1
392 else -> localQueueIndex
393 }
394 updateQueueInState()
395 }
396
397 override suspend fun clearQueue() {
398 // Maintainer, 2026-10-29: only stopped, so the queue stayed. Stop, then
399 // empty the phone-side queue and publish it.
400 stop()
401 localQueue = emptyList()
402 localQueueIndex = 0
403 _state.update {
404 it.copy(queue = emptyList(), current = null, queueIndex = -1, isPlaying = false, isStopped = true)
405 }
406 }
407
408 override suspend fun setRepeatMode(mode: RepeatMode) {
409 // Maintainer, 2026-10-29: keep the mode in our state (the UI cycles from
410 // it; it was never set, so every press sent ALL). Repeat is applied by
411 // our own advance logic (nextAutoIndex). See remotePlayMode for what
412 // the device itself is told.
413 _state.update { it.copy(repeatMode = mode) }
414 invokeAvtAction("SetPlayMode", mapOf("NewPlayMode" to remotePlayMode()))
415 }
416
417 override suspend fun setShuffle(enabled: Boolean) {
418 // Shuffle and repeat aren't independently representable in UPnP — see
419 // EarhornAVTransportService.setPlayMode for the inverse mapping. We send
420 // SHUFFLE when on, NORMAL when off. If the user has also set repeat, that
421 // wins per the inverse mapping in the renderer's setPlayMode.
422 // Maintainer, 2026-10-29: keep the flag in our state so the UI toggle
423 // turns it off again (it always sent SHUFFLE). Off now restores the
424 // repeat mode on the device instead of NORMAL. Shuffled advance is not
425 // implemented for remote renderers: the queue still plays in order.
426 _state.update { it.copy(shuffleEnabled = enabled) }
427 invokeAvtAction("SetPlayMode", mapOf("NewPlayMode" to remotePlayMode()))
428 }
429
430 /** Maintainer, 2026-10-29: the PlayMode the device itself gets. It only ever
431 * holds ONE track (we send them one at a time), so REPEAT_ALL there just
432 * loops that track and our queue never advances — repeat ALL is ours to
433 * do, and the device stays NORMAL. REPEAT_ONE on the device is the same
434 * thing we'd do. */
435 private fun remotePlayMode(): String {
436 val s = _state.value
437 return when {
438 s.shuffleEnabled -> "SHUFFLE"
439 s.repeatMode == RepeatMode.ONE -> "REPEAT_ONE"
440 else -> "NORMAL"
441 }
442 }
443
444 // ---- Initial state pulls ------------------------------------------------
445
446 private suspend fun pullInitialAvtState(device: RemoteDevice) {
447 val info = invokeAvtAction(device, "GetTransportInfo", emptyMap())
448 val transportStateStr = info?.get("CurrentTransportState") ?: return
449 val media = invokeAvtAction(device, "GetMediaInfo", emptyMap())
450 val mediaUri = media?.get("CurrentURI").orEmpty()
451 val mediaDuration = media?.get("MediaDuration").orEmpty()
452 val mediaMeta = media?.get("CurrentURIMetaData").orEmpty()
453 val track = DidlLiteCodec.decodeFirstTrack(mediaMeta, mediaUri)
454 _state.value = _state.value.copy(
455 current = track ?: _state.value.current,
456 isPlaying = transportStateStr == "PLAYING",
457 isStopped = transportStateStr == "STOPPED" || transportStateStr == "NO_MEDIA_PRESENT",
458 isBuffering = transportStateStr == "TRANSITIONING",
459 duration = parseHmsToDuration(mediaDuration) ?: Duration.ZERO,
460 )
461 }
462
463 private suspend fun pullInitialVolume(device: RemoteDevice) {
464 val out = invokeRcAction(device, "GetVolume", mapOf("Channel" to "Master"))
465 val pct = out?.get("CurrentVolume")?.toIntOrNull() ?: return
466 _state.value = _state.value.copy(volumePercent = pct.coerceIn(0, 100))
467 }
468
469 // ---- LastChange parsing -------------------------------------------------
470
471 private val avtParser by lazy { AVTransportLastChangeParser() }
472 private val rcParser by lazy { RenderingControlLastChangeParser() }
473
474 private fun parseAvtLastChange(xml: String) {
475 // Try the proper jUPnP LastChangeParser first. If it fails (missing XSD
476 // schema files at the legacy org/fourthline/cling/ classpath), fall back
477 // to simple regex extraction from the raw XML.
478 val parsed = runCatching {
479 val event = avtParser.parse(xml) ?: return@runCatching false
480 val instance = event.instanceIDs?.firstOrNull() ?: return@runCatching false
481 val values = instance.values ?: return@runCatching false
482 var cur = _state.value
483 values.forEach { v: EventedValue<*> ->
484 when (v) {
485 is AVTransportVariable.TransportState -> {
486 val s = v.value
487 val stopped = s == org.jupnp.support.model.TransportState.STOPPED ||
488 s == org.jupnp.support.model.TransportState.NO_MEDIA_PRESENT
489 cur = cur.copy(
490 isPlaying = s == org.jupnp.support.model.TransportState.PLAYING,
491 isStopped = stopped,
492 isBuffering = s == org.jupnp.support.model.TransportState.TRANSITIONING,
493 )
494 if (stopped && shouldAutoAdvance()) {
495 scope.launch { autoAdvance() } // Maintainer, 2026-10-29: honours repeat
496 } else if (stopped) {
497 userRequestedStop = true // a stop from anywhere is a stop
498 }
499 }
500 is AVTransportVariable.CurrentTrackMetaData -> {
501 val didl = v.value
502 val uriHint = cur.current?.resourceUrl.orEmpty()
503 DidlLiteCodec.decodeFirstTrack(didl, uriHint)?.let {
504 cur = cur.copy(current = it)
505 }
506 }
507 is AVTransportVariable.CurrentTrackDuration -> {
508 parseHmsToDuration(v.value)?.let { cur = cur.copy(duration = it) }
509 }
510 else -> { /* ignore */ }
511 }
512 }
513 _state.value = cur
514 true
515 }.getOrDefault(false)
516
517 if (!parsed) {
518 // Fallback: extract transport state + metadata from raw LastChange XML
519 // without schema validation.
520 log("AVT LastChange parser failed, using XML fallback")
521 parseAvtLastChangeFallback(xml)
522 }
523 }
524
525 /** Regex-based fallback for LastChange XML when the jUPnP parser can't find
526 * the XSD schema files. Extracts TransportState and CurrentTrackMetaData
527 * directly from the XML attributes. */
528 private fun parseAvtLastChangeFallback(xml: String) {
529 runCatching {
530 var cur = _state.value
531 // TransportState: <TransportState val="PLAYING"/>
532 val tsMatch = Regex("<TransportState\\s+val=\"([^\"]+)\"").find(xml)
533 if (tsMatch != null) {
534 val ts = tsMatch.groupValues[1]
535 val stopped = ts == "STOPPED" || ts == "NO_MEDIA_PRESENT"
536 cur = cur.copy(
537 isPlaying = ts == "PLAYING",
538 isStopped = stopped,
539 isBuffering = ts == "TRANSITIONING",
540 )
541 if (stopped && shouldAutoAdvance()) {
542 scope.launch { autoAdvance() } // Maintainer, 2026-10-29: honours repeat
543 } else if (stopped) {
544 userRequestedStop = true // a stop from anywhere is a stop
545 }
546 }
547 // CurrentTrackMetaData: <CurrentTrackMetaData val="...encoded DIDL..."/>
548 val metaMatch = Regex("<CurrentTrackMetaData\\s+val=\"([^\"]*?)\"\\s*/>").find(xml)
549 if (metaMatch != null) {
550 val encoded = metaMatch.groupValues[1]
551 if (encoded.isNotBlank() && encoded != "NOT_IMPLEMENTED") {
552 val decoded = encoded
553 .replace("&lt;", "<").replace("&gt;", ">")
554 .replace("&amp;", "&").replace("&quot;", "\"")
555 .replace("&apos;", "'")
556 val uriHint = cur.current?.resourceUrl.orEmpty()
557 DidlLiteCodec.decodeFirstTrack(decoded, uriHint)?.let {
558 cur = cur.copy(current = it)
559 }
560 }
561 }
562 // CurrentTrackDuration: <CurrentTrackDuration val="0:03:45"/>
563 val durMatch = Regex("<CurrentTrackDuration\\s+val=\"([^\"]+)\"").find(xml)
564 if (durMatch != null) {
565 parseHmsToDuration(durMatch.groupValues[1])?.let {
566 cur = cur.copy(duration = it)
567 }
568 }
569 _state.value = cur
570 }.onFailure { log("AVT fallback parser also failed: ${it.message}") }
571 }
572
573 private fun parseRcLastChange(xml: String) {
574 runCatching {
575 val event = rcParser.parse(xml) ?: return@runCatching
576 val instance = event.instanceIDs?.firstOrNull() ?: return@runCatching
577 val values = instance.values ?: return@runCatching
578 var cur = _state.value
579 values.forEach { v: EventedValue<*> ->
580 when (v) {
581 is RenderingControlVariable.Volume -> {
582 // ChannelVolume getters: channel + volume (Integer).
583 val cv = v.value
584 if (cv?.channel == org.jupnp.support.model.Channel.Master) {
585 cv.volume?.toInt()?.coerceIn(0, 100)?.let { vol ->
586 cur = cur.copy(volumePercent = vol)
587 }
588 }
589 }
590 is RenderingControlVariable.Mute -> {
591 // We don't track mute in PlaybackState; volume = 0 conveys it.
592 }
593 else -> { /* Loudness, PresetNameList, etc. — ignored */ }
594 }
595 }
596 _state.value = cur
597 }.onFailure { log("parseRcLastChange threw: ${it.message}") }
598 }
599
600 // ---- Position poller ----------------------------------------------------
601
602 /** Poll GetTransportInfo (1 s playing / 10 s otherwise) and GetPositionInfo
603 * while playing. Also the auto-advance path when GENA events don't arrive. */
604 private fun startPositionPoller() {
605 positionPollerJob?.cancel()
606 positionPollerJob = scope.launch {
607 while (true) {
608 val device = findRemoteDevice()
609 if (device != null) {
610 // Poll transport state — critical when GENA subscriptions
611 // fail (missing XSD schema files on Android).
612 runCatching {
613 val info = invokeAvtAction(device, "GetTransportInfo", emptyMap())
614 val ts = info?.get("CurrentTransportState").orEmpty()
615 val wasStopped = _state.value.isStopped
616 val nowStopped = ts == "STOPPED" || ts == "NO_MEDIA_PRESENT"
617 _state.value = _state.value.copy(
618 isPlaying = ts == "PLAYING",
619 isStopped = nowStopped,
620 isBuffering = ts == "TRANSITIONING",
621 )
622 // Auto-advance only when OUR track reached its end.
623 if (nowStopped && !wasStopped && shouldAutoAdvance()) {
624 autoAdvance() // Maintainer, 2026-10-29: honours repeat
625 } else if (nowStopped && !wasStopped) {
626 userRequestedStop = true // a stop from anywhere is a stop
627 }
628 }
629 // Poll position when playing; remember it so the end-of-
630 // track test below has something to compare against.
631 if (_state.value.isPlaying) {
632 runCatching {
633 val out = invokeAvtAction(device, "GetPositionInfo", emptyMap())
634 val relTime = out?.get("RelTime").orEmpty()
635 parseHmsToDuration(relTime)?.let {
636 _state.value = _state.value.copy(position = it)
637 lastSeenPosition = it
638 }
639 // Also grab duration if we don't have it
640 if (_state.value.duration == kotlin.time.Duration.ZERO) {
641 val durStr = out?.get("TrackDuration").orEmpty()
642 parseHmsToDuration(durStr)?.let {
643 _state.value = _state.value.copy(duration = it)
644 }
645 }
646 }
647 }
648 }
649 // design decision 2026-09-02: once a second while playing,
650 // every ten seconds while paused/stopped — still finds out
651 // when someone presses Play on the speaker itself, without
652 // sixty requests a minute for nothing.
653 delay(if (_state.value.isPlaying) 1_000 else 10_000)
654 }
655 }
656 }
657
658 /** Position seen at the last poll while playing; used to decide whether
659 * a STOPPED means "our track finished" or "someone stopped it". */
660 @Volatile private var lastSeenPosition: Duration = Duration.ZERO
661
662 /** design decision 2026-09-02: the phone advances to the next song only
663 * when its OWN track played to the end. Any other stop — the speaker's
664 * buttons, another app — is a stop, and the phone sends nothing.
665 * "Reached the end": the last position we saw was within a few seconds
666 * of the track's duration (or the duration is unknown and we saw the
667 * track play for at least as long as its declared length is absent —
668 * in which case we cannot tell, and we do NOT advance). */
669 private fun shouldAutoAdvance(): Boolean {
670 if (userRequestedStop) return false
671 if (System.currentTimeMillis() - lastTrackSentAt <= 5000) return false
672 // Maintainer, 2026-10-29: "is there a next track" now follows repeat.
673 if (nextAutoIndex() == null) return false
674 val dur = _state.value.duration
675 if (dur <= Duration.ZERO) return false
676 val remaining = dur - lastSeenPosition
677 return remaining <= 3.seconds
678 }
679
680 /** Maintainer, 2026-10-29: where playback goes when OUR track ended —
681 * repeat ONE replays it, repeat ALL wraps to the start, otherwise the
682 * next track or nothing. Shuffle isn't applied (plays in order). */
683 private fun nextAutoIndex(): Int? = when {
684 localQueue.isEmpty() -> null
685 _state.value.repeatMode == RepeatMode.ONE -> localQueueIndex
686 localQueueIndex < localQueue.size - 1 -> localQueueIndex + 1
687 _state.value.repeatMode == RepeatMode.ALL -> 0
688 else -> null
689 }
690
691 /** Maintainer, 2026-10-29: the auto-advance itself (was a bare +1). */
692 private suspend fun autoAdvance() {
693 val next = nextAutoIndex() ?: return
694 sendTrackAtIndex(next, autoplay = true)
695 }
696
697 // ---- jUPnP action plumbing ----------------------------------------------
698
699 /** Invoke a named action on the remote device's AVTransport service. Returns the
700 * output args by name, or null on failure. Suspends until the action completes. */
701 private suspend fun invokeAvtAction(
702 actionName: String,
703 args: Map<String, String> = emptyMap(),
704 ): Map<String, String>? {
705 val device = findRemoteDevice() ?: return null
706 return invokeAvtAction(device, actionName, args)
707 }
708
709 private suspend fun invokeAvtAction(
710 device: RemoteDevice,
711 actionName: String,
712 args: Map<String, String>,
713 ): Map<String, String>? {
714 val service = device.findAvtService() ?: return null
715 return invokeOnService(service, actionName, args)
716 }
717
718 private suspend fun invokeRcAction(
719 actionName: String,
720 args: Map<String, String> = emptyMap(),
721 ): Map<String, String>? {
722 val device = findRemoteDevice() ?: return null
723 return invokeRcAction(device, actionName, args)
724 }
725
726 private suspend fun invokeRcAction(
727 device: RemoteDevice,
728 actionName: String,
729 args: Map<String, String>,
730 ): Map<String, String>? {
731 val service = device.findRcService() ?: return null
732 return invokeOnService(service, actionName, args)
733 }
734
735 /** Core suspend-wrap of jUPnP's callback-based execute(). Always includes
736 * `InstanceID=0` as the first argument since Earhorn is single-instance. */
737 private suspend fun invokeOnService(
738 service: RemoteService,
739 actionName: String,
740 args: Map<String, String>,
741 ): Map<String, String>? {
742 val action = service.getAction(actionName) ?: run {
743 log("Service has no '$actionName' action")
744 return null
745 }
746 val controlPoint = holder.controlPoint ?: return null
747
748 return suspendCancellableCoroutine { cont ->
749 val invocation = ActionInvocation(action)
750 // InstanceID is the conventional first arg for both AVT and RC.
751 // Renderers that don't support multi-instance ignore non-zero IDs.
752 runCatching { invocation.setInput("InstanceID", "0") }
753 args.forEach { (k, v) ->
754 runCatching { invocation.setInput(k, v) }
755 }
756
757 val callback = object : ActionCallback(invocation) {
758 override fun success(inv: ActionInvocation<*>?) {
759 val outputs = inv?.toOutputMap().orEmpty()
760 if (cont.isActive) cont.resume(outputs)
761 }
762 override fun failure(
763 inv: ActionInvocation<*>?,
764 operation: UpnpResponse?,
765 defaultMsg: String?,
766 ) {
767 val cause = inv?.failure as? ActionException ?: ActionException(
768 org.jupnp.model.types.ErrorCode.ACTION_FAILED,
769 defaultMsg ?: "Unknown error"
770 )
771 log("Action $actionName failed: ${cause.message}")
772 if (cont.isActive) cont.resume(null)
773 }
774 }
775 controlPoint.execute(callback)
776 }
777 }
778
779 /** Convert an [ActionInvocation]'s output arguments into a name→string-value map.
780 * Function (not property) to avoid colliding with whatever name jUPnP may already
781 * expose on the class — extension properties don't override member properties,
782 * and the compiler errors get confusing fast. */
783 private fun ActionInvocation<*>.toOutputMap(): Map<String, String> =
784 output.orEmpty().associate { it.argument.name to (it.value?.toString().orEmpty()) }
785
786 // ---- Helpers ------------------------------------------------------------
787
788 private fun findRemoteDevice(): RemoteDevice? = holder.findDevice(target.udn)
789
790 private fun RemoteDevice.findAvtService(): RemoteService? =
791 findService(UDAServiceType("AVTransport", 1)) as? RemoteService
792
793 private fun RemoteDevice.findRcService(): RemoteService? =
794 findService(UDAServiceType("RenderingControl", 1)) as? RemoteService
795
796 private fun formatDurationAsUpnpClock(duration: Duration): String {
797 val totalMs = duration.inWholeMilliseconds.coerceAtLeast(0)
798 val ms = totalMs % 1000
799 val total = totalMs / 1000
800 val h = total / 3600
801 val m = (total % 3600) / 60
802 val s = total % 60
803 return "%d:%02d:%02d.%03d".format(h, m, s, ms)
804 }
805
806 private fun parseHmsToDuration(clock: String?): Duration? {
807 if (clock.isNullOrBlank() || clock == "NOT_IMPLEMENTED") return null
808 val parts = clock.split(":")
809 if (parts.size != 3) return null
810 val h = parts[0].toLongOrNull() ?: return null
811 val m = parts[1].toLongOrNull() ?: return null
812 val s = parts[2].toDoubleOrNull() ?: return null
813 val ms = ((h * 3600 + m * 60) * 1000 + (s * 1000).toLong())
814 return ms.milliseconds
815 }
816
817 private fun log(msg: String) {
818 android.util.Log.i("EarhornUpnp", "[RemoteUpnpRenderer/${target.friendlyName}] $msg")
819 }
820}