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

RemoteUpnpRenderer.kt

package dev.stave.upnp.renderer

import dev.stave.domain.model.DidlItem
import dev.stave.domain.model.RemoteRenderer
import dev.stave.domain.renderer.PlaybackState
import dev.stave.domain.renderer.RepeatMode
import dev.stave.domain.renderer.Renderer
import dev.stave.upnp.UpnpServiceHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.jupnp.controlpoint.ActionCallback
import org.jupnp.controlpoint.SubscriptionCallback
import org.jupnp.model.action.ActionException
import org.jupnp.model.action.ActionInvocation
import org.jupnp.model.gena.CancelReason
import org.jupnp.model.gena.GENASubscription
import org.jupnp.model.message.UpnpResponse
import org.jupnp.model.meta.RemoteDevice
import org.jupnp.model.meta.RemoteService
import org.jupnp.model.types.UDAServiceType
import org.jupnp.support.avtransport.lastchange.AVTransportLastChangeParser
import org.jupnp.support.avtransport.lastchange.AVTransportVariable
import org.jupnp.support.lastchange.Event
import org.jupnp.support.lastchange.EventedValue
import org.jupnp.support.renderingcontrol.lastchange.RenderingControlLastChangeParser
import org.jupnp.support.renderingcontrol.lastchange.RenderingControlVariable
import kotlin.coroutines.resume
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

/**
 * A [Renderer] implementation that drives a REMOTE UPnP MediaRenderer device discovered on
 * the LAN. The remote device is the actual audio source (another Earhorn instance, a Linn
 * streamer, gmediarender, BubbleUPnP receiver, etc.) — this class is the control-point
 * adapter that makes a remote renderer look like a local one to the rest of the app.
 *
 * Two halves of behavior:
 *
 *   1. **Outgoing actions.** [play], [pause], [stop], [seekTo], [next], [previous],
 *      [setVolume] each translate to a UPnP action invoked on the remote device's
 *      AVTransport or RenderingControl service. We use jUPnP's `ControlPoint.execute`
 *      with [ActionCallback], wrapped in [suspendCancellableCoroutine] so callers can
 *      `suspend` on completion the same way they do with the local renderer.
 *
 *   2. **Incoming state.** We open GENA subscriptions to the remote device's AVTransport
 *      and RenderingControl `LastChange` evented state variables. Push notifications
 *      from the renderer arrive as [eventReceived] callbacks; we parse the LastChange
 *      payload with the service-specific parser and update [state] accordingly. Plus
 *      a fallback poller for position (which UPnP doesn't event — it's pull-only via
 *      `GetPositionInfo`).
 *
 * **Queue.** This class keeps the queue locally and feeds the renderer one
 * track at a time via SetAVTransportURI ([load], [enqueue], [playNext],
 * [skipToQueueItem], [removeFromQueue], [moveInQueue], [clearQueue]).
 * Maintainer, 2026-10-29: moveInQueue/clearQueue implemented; tracks the device
 * can't fetch (local files) are skipped.
 * (An earlier version of this header described all of these as no-ops.)
 *
 * **Lifecycle.**
 *
 *   The coordinator (next iteration) constructs a fresh [RemoteUpnpRenderer] each time
 *   the user picks a different remote renderer, calls [attach] to open subscriptions,
 *   and calls [detach] when switching away or shutting down. [detach] cancels the
 *   subscription and stops the position poller cleanly so we don't leak GENA listeners
 *   on the remote device — even Earhorn-to-Earhorn subscriptions don't auto-clean up
 *   if the controller process dies abruptly, so detaching is hygienic for graceful
 *   shutdown.
 */
class RemoteUpnpRenderer(
    private val target: RemoteRenderer,
    private val holder: UpnpServiceHolder,
) : Renderer {

    /** UDN of the remote renderer this instance is bound to. Public so the
     *  coordinator can match an attached instance against the user's selection
     *  without holding a separate lookup table. Stable for the life of the
     *  instance — to drive a different renderer, build a new instance. */
    val targetUdn: String get() = target.udn

    /** Display name of the remote — likewise public for the same reason. */
    val targetFriendlyName: String get() = target.friendlyName

    private val _state = MutableStateFlow(PlaybackState())
    override val state: StateFlow<PlaybackState> = _state.asStateFlow()

    // ---- Local queue driver (phone-side queue for remote renderer) ----------
    private var localQueue: List<DidlItem.Track> = emptyList()
    private var localQueueIndex: Int = 0
    private var userRequestedStop: Boolean = false
    private var lastTrackSentAt: Long = 0L

    private fun updateQueueInState() {
        _state.value = _state.value.copy(
            queue = localQueue,
            queueIndex = localQueueIndex,
            current = localQueue.getOrNull(localQueueIndex) ?: _state.value.current,
        )
    }

    /** Maintainer, 2026-10-29: true when the remote device can fetch [track].
     *  Local files (content://, file://) only exist on this device. */
    private fun isRemotePlayable(track: DidlItem.Track): Boolean =
        track.resourceUrl.startsWith("http://", ignoreCase = true) ||
            track.resourceUrl.startsWith("https://", ignoreCase = true)

    /** Maintainer, 2026-10-29: skips tracks the remote can't fetch (local files
     *  were sent as content:// URIs and failed silently), moving by [step]
     *  from [index] to the next playable one; stops (step > 0) when there is
     *  none. Returns true when a track was sent. */
    private suspend fun sendTrackAtIndex(index: Int, autoplay: Boolean = true, step: Int = 1): Boolean {
        lastTrackSentAt = System.currentTimeMillis()
        if (index !in localQueue.indices) return false
        var playable = index
        while (playable in localQueue.indices && !isRemotePlayable(localQueue[playable])) playable += step
        val track = localQueue.getOrNull(playable) ?: run {
            log("No http(s) track from index $index on — local files can't play on ${target.friendlyName}")
            if (step > 0) stop()
            return false
        }
        if (playable != index) log("Skipped ${kotlin.math.abs(playable - index)} local-only track(s)")
        localQueueIndex = playable
        val didl = DidlLiteCodec.encodeTrack(track)
        invokeAvtAction(
            "SetAVTransportURI",
            mapOf(
                "CurrentURI" to track.resourceUrl,
                "CurrentURIMetaData" to didl,
            )
        )
        updateQueueInState()
        if (autoplay) play()
        return true
    }

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    /** Subscription tickets we need to cancel on detach. jUPnP's
     *  [SubscriptionCallback.end] sends the UNSUBSCRIBE request to the renderer. */
    private var avtSubscription: SubscriptionCallback? = null
    private var rcSubscription: SubscriptionCallback? = null

    /** Position poller. UPnP doesn't event the current track position — it's only
     *  queryable via [GetPositionInfo]. Polls transport state once a second while
     *  playing and every ten seconds otherwise; position only while playing. */
    private var positionPollerJob: Job? = null

    // ---- Lifecycle ----------------------------------------------------------

    /**
     * Open the GENA subscriptions and prime [state] with an initial snapshot. Idempotent;
     * calling twice is a no-op. Doesn't throw — failures are logged and the renderer
     * comes up in a degraded "connected but no events" mode that callers can still issue
     * actions against (each action runs an independent invocation).
     */
    fun attach() {
        if (avtSubscription != null) return  // already attached
        log("attach() target=${target.friendlyName} udn=${target.udn}")

        val device = findRemoteDevice()
        if (device == null) {
            log("Device with UDN ${target.udn} is not in the registry — cannot attach")
            return
        }

        // Initial best-effort state pull via individual GetXxx actions. The renderer
        // will then keep us in sync via GENA events. If GENA fails (some bargain-bin
        // renderers don't fire events even though they accept the subscription), the
        // initial snapshot is at least useful and the action surface still works.
        scope.launch {
            runCatching { pullInitialAvtState(device) }
                .onFailure { log("pullInitialAvtState failed: ${it.message}") }
            runCatching { pullInitialVolume(device) }
                .onFailure { log("pullInitialVolume failed: ${it.message}") }
        }

        device.findAvtService()?.let { svc ->
            val cb = object : SubscriptionCallback(svc) {
                override fun established(s: GENASubscription<*>) {
                    log("AVT subscription established")
                }
                override fun ended(
                    s: GENASubscription<*>,
                    reason: CancelReason?,
                    resp: UpnpResponse?,
                ) {
                    log("AVT subscription ended reason=$reason rc=${resp?.statusCode}")
                }
                override fun eventReceived(s: GENASubscription<*>) {
                    val raw = s.currentValues["LastChange"]?.value as? String ?: return
                    parseAvtLastChange(raw)
                }
                override fun eventsMissed(s: GENASubscription<*>, count: Int) {
                    log("AVT subscription missed $count event(s) — state may be stale")
                }
                override fun failed(
                    s: GENASubscription<*>?,
                    resp: UpnpResponse?,
                    ex: Exception?,
                    defaultMsg: String,
                ) {
                    log("AVT subscription failed: $defaultMsg ${ex?.message ?: ""}")
                }
            }
            holder.controlPoint?.execute(cb)
            avtSubscription = cb
        } ?: log("Remote device has no AVTransport service — degraded mode")

        device.findRcService()?.let { svc ->
            val cb = object : SubscriptionCallback(svc) {
                override fun established(s: GENASubscription<*>) {}
                override fun ended(
                    s: GENASubscription<*>,
                    reason: CancelReason?,
                    resp: UpnpResponse?,
                ) {}
                override fun eventReceived(s: GENASubscription<*>) {
                    val raw = s.currentValues["LastChange"]?.value as? String ?: return
                    parseRcLastChange(raw)
                }
                override fun eventsMissed(s: GENASubscription<*>, count: Int) {}
                override fun failed(
                    s: GENASubscription<*>?,
                    resp: UpnpResponse?,
                    ex: Exception?,
                    defaultMsg: String,
                ) {
                    log("RC subscription failed: $defaultMsg")
                }
            }
            holder.controlPoint?.execute(cb)
            rcSubscription = cb
        } ?: log("Remote device has no RenderingControl service — volume disabled")

        startPositionPoller()
    }

    /** Cancel GENA subscriptions, stop pollers, and release the coroutine scope.
     *  Safe to call multiple times; subsequent calls are no-ops. */
    fun detach() {
        log("detach() target=${target.friendlyName}")
        runCatching { avtSubscription?.end() }
        runCatching { rcSubscription?.end() }
        avtSubscription = null
        rcSubscription = null
        positionPollerJob?.cancel()
        positionPollerJob = null
        scope.cancel()
    }

    // ---- Outgoing actions (Renderer interface) ------------------------------

    override suspend fun play() {
        userRequestedStop = false
        invokeAvtAction("Play", mapOf("Speed" to "1"))
    }

    override suspend fun pause() {
        invokeAvtAction("Pause")
    }

    override suspend fun stop() {
        userRequestedStop = true
        // Clear gapless prefetch so the remote doesn't auto-advance after Stop.
        runCatching {
            invokeAvtAction("SetNextAVTransportURI", mapOf(
                "NextURI" to "",
                "NextURIMetaData" to "",
            ))
        }
        invokeAvtAction("Stop")
    }

    override suspend fun seekTo(position: Duration) {
        invokeAvtAction(
            "Seek",
            mapOf(
                "Unit" to "REL_TIME",
                "Target" to formatDurationAsUpnpClock(position),
            )
        )
    }

    override suspend fun next() {
        if (localQueueIndex < localQueue.size - 1) {
            sendTrackAtIndex(localQueueIndex + 1, autoplay = true)
        } else {
            invokeAvtAction("Next")
        }
    }

    override suspend fun previous() {
        if (localQueueIndex > 0) {
            // Maintainer, 2026-10-29: step back past local-only tracks.
            sendTrackAtIndex(localQueueIndex - 1, autoplay = true, step = -1)
        } else {
            invokeAvtAction("Previous")
        }
    }

    override suspend fun setVolume(volumePercent: Int) {
        invokeRcAction(
            "SetVolume",
            mapOf(
                "Channel" to "Master",
                "DesiredVolume" to volumePercent.coerceIn(0, 100).toString(),
            )
        )
    }

    /**
     * Loads a queue of tracks. The phone keeps the full queue locally and feeds
     * tracks one at a time to the remote renderer via SetAVTransportURI. Auto-
     * advance happens when the transport state goes STOPPED after a natural
     * track end (not user-initiated stop).
     */
    override suspend fun load(
        tracks: List<DidlItem.Track>,
        startIndex: Int,
        autoplay: Boolean,
    ) {
        localQueue = tracks.toList()
        userRequestedStop = false
        log("load() ${tracks.size} tracks, startIndex=$startIndex, autoplay=$autoplay")
        // Maintainer, 2026-10-29: when nothing from startIndex on is playable here
        // (all local files), still publish the new queue so the screen isn't stale.
        if (!sendTrackAtIndex(startIndex, autoplay)) updateQueueInState()
    }

    // ---- Queue mutation (no-op pending Phase 2 part C: queue driver) --------

    override suspend fun enqueue(tracks: List<DidlItem.Track>) {
        localQueue = localQueue + tracks
        updateQueueInState()
        log("enqueue() added ${tracks.size} tracks, queue now ${localQueue.size}")
    }
    override suspend fun playNext(tracks: List<DidlItem.Track>) {
        val insertAt = localQueueIndex + 1
        localQueue = localQueue.toMutableList().apply { addAll(insertAt.coerceAtMost(size), tracks) }
        updateQueueInState()
        log("playNext() inserted ${tracks.size} tracks at $insertAt")
    }
    override suspend fun skipToQueueItem(index: Int) {
        if (index in localQueue.indices) {
            sendTrackAtIndex(index, autoplay = true)
        }
    }
    override suspend fun removeFromQueue(index: Int) {
        if (index !in localQueue.indices) return
        if (index == localQueueIndex) {
            // Maintainer, 2026-10-29: removing the CURRENT track was ignored.
            // Move on to the next (playable) track first, keeping play/pause,
            // or stop when there is none; then remove it.
            val wasPlaying = _state.value.isPlaying
            if (index < localQueue.lastIndex) {
                // Paused: the next track is loaded but waits for Play, which
                // must not read as "our track ended" (see shouldAutoAdvance).
                if (sendTrackAtIndex(index + 1, autoplay = wasPlaying) && !wasPlaying) userRequestedStop = true
            } else {
                stop()
            }
        }
        localQueue = localQueue.toMutableList().apply { removeAt(index) }
        if (index < localQueueIndex) localQueueIndex--
        if (localQueue.isEmpty()) {
            localQueueIndex = 0
            _state.update { it.copy(queue = emptyList(), current = null, queueIndex = -1) }
        } else {
            localQueueIndex = localQueueIndex.coerceIn(0, localQueue.lastIndex)
            updateQueueInState()
        }
    }
    override suspend fun moveInQueue(from: Int, to: Int) {
        // Maintainer, 2026-10-29: was a no-op. Reorder the phone-side queue;
        // the current track keeps playing and keeps being "current".
        if (from !in localQueue.indices || to !in localQueue.indices || from == to) return
        localQueue = localQueue.toMutableList().apply { add(to, removeAt(from)) }
        localQueueIndex = when {
            localQueueIndex == from -> to
            from < localQueueIndex && to >= localQueueIndex -> localQueueIndex - 1
            from > localQueueIndex && to <= localQueueIndex -> localQueueIndex + 1
            else -> localQueueIndex
        }
        updateQueueInState()
    }

    override suspend fun clearQueue() {
        // Maintainer, 2026-10-29: only stopped, so the queue stayed. Stop, then
        // empty the phone-side queue and publish it.
        stop()
        localQueue = emptyList()
        localQueueIndex = 0
        _state.update {
            it.copy(queue = emptyList(), current = null, queueIndex = -1, isPlaying = false, isStopped = true)
        }
    }

    override suspend fun setRepeatMode(mode: RepeatMode) {
        // Maintainer, 2026-10-29: keep the mode in our state (the UI cycles from
        // it; it was never set, so every press sent ALL). Repeat is applied by
        // our own advance logic (nextAutoIndex). See remotePlayMode for what
        // the device itself is told.
        _state.update { it.copy(repeatMode = mode) }
        invokeAvtAction("SetPlayMode", mapOf("NewPlayMode" to remotePlayMode()))
    }

    override suspend fun setShuffle(enabled: Boolean) {
        // Shuffle and repeat aren't independently representable in UPnP — see
        // EarhornAVTransportService.setPlayMode for the inverse mapping. We send
        // SHUFFLE when on, NORMAL when off. If the user has also set repeat, that
        // wins per the inverse mapping in the renderer's setPlayMode.
        // Maintainer, 2026-10-29: keep the flag in our state so the UI toggle
        // turns it off again (it always sent SHUFFLE). Off now restores the
        // repeat mode on the device instead of NORMAL. Shuffled advance is not
        // implemented for remote renderers: the queue still plays in order.
        _state.update { it.copy(shuffleEnabled = enabled) }
        invokeAvtAction("SetPlayMode", mapOf("NewPlayMode" to remotePlayMode()))
    }

    /** Maintainer, 2026-10-29: the PlayMode the device itself gets. It only ever
     *  holds ONE track (we send them one at a time), so REPEAT_ALL there just
     *  loops that track and our queue never advances — repeat ALL is ours to
     *  do, and the device stays NORMAL. REPEAT_ONE on the device is the same
     *  thing we'd do. */
    private fun remotePlayMode(): String {
        val s = _state.value
        return when {
            s.shuffleEnabled -> "SHUFFLE"
            s.repeatMode == RepeatMode.ONE -> "REPEAT_ONE"
            else -> "NORMAL"
        }
    }

    // ---- Initial state pulls ------------------------------------------------

    private suspend fun pullInitialAvtState(device: RemoteDevice) {
        val info = invokeAvtAction(device, "GetTransportInfo", emptyMap())
        val transportStateStr = info?.get("CurrentTransportState") ?: return
        val media = invokeAvtAction(device, "GetMediaInfo", emptyMap())
        val mediaUri = media?.get("CurrentURI").orEmpty()
        val mediaDuration = media?.get("MediaDuration").orEmpty()
        val mediaMeta = media?.get("CurrentURIMetaData").orEmpty()
        val track = DidlLiteCodec.decodeFirstTrack(mediaMeta, mediaUri)
        _state.value = _state.value.copy(
            current = track ?: _state.value.current,
            isPlaying = transportStateStr == "PLAYING",
            isStopped = transportStateStr == "STOPPED" || transportStateStr == "NO_MEDIA_PRESENT",
            isBuffering = transportStateStr == "TRANSITIONING",
            duration = parseHmsToDuration(mediaDuration) ?: Duration.ZERO,
        )
    }

    private suspend fun pullInitialVolume(device: RemoteDevice) {
        val out = invokeRcAction(device, "GetVolume", mapOf("Channel" to "Master"))
        val pct = out?.get("CurrentVolume")?.toIntOrNull() ?: return
        _state.value = _state.value.copy(volumePercent = pct.coerceIn(0, 100))
    }

    // ---- LastChange parsing -------------------------------------------------

    private val avtParser by lazy { AVTransportLastChangeParser() }
    private val rcParser by lazy { RenderingControlLastChangeParser() }

    private fun parseAvtLastChange(xml: String) {
        // Try the proper jUPnP LastChangeParser first. If it fails (missing XSD
        // schema files at the legacy org/fourthline/cling/ classpath), fall back
        // to simple regex extraction from the raw XML.
        val parsed = runCatching {
            val event = avtParser.parse(xml) ?: return@runCatching false
            val instance = event.instanceIDs?.firstOrNull() ?: return@runCatching false
            val values = instance.values ?: return@runCatching false
            var cur = _state.value
            values.forEach { v: EventedValue<*> ->
                when (v) {
                    is AVTransportVariable.TransportState -> {
                        val s = v.value
                        val stopped = s == org.jupnp.support.model.TransportState.STOPPED ||
                            s == org.jupnp.support.model.TransportState.NO_MEDIA_PRESENT
                        cur = cur.copy(
                            isPlaying = s == org.jupnp.support.model.TransportState.PLAYING,
                            isStopped = stopped,
                            isBuffering = s == org.jupnp.support.model.TransportState.TRANSITIONING,
                        )
                        if (stopped && shouldAutoAdvance()) {
                            scope.launch { autoAdvance() } // Maintainer, 2026-10-29: honours repeat
                        } else if (stopped) {
                            userRequestedStop = true   // a stop from anywhere is a stop
                        }
                    }
                    is AVTransportVariable.CurrentTrackMetaData -> {
                        val didl = v.value
                        val uriHint = cur.current?.resourceUrl.orEmpty()
                        DidlLiteCodec.decodeFirstTrack(didl, uriHint)?.let {
                            cur = cur.copy(current = it)
                        }
                    }
                    is AVTransportVariable.CurrentTrackDuration -> {
                        parseHmsToDuration(v.value)?.let { cur = cur.copy(duration = it) }
                    }
                    else -> { /* ignore */ }
                }
            }
            _state.value = cur
            true
        }.getOrDefault(false)

        if (!parsed) {
            // Fallback: extract transport state + metadata from raw LastChange XML
            // without schema validation.
            log("AVT LastChange parser failed, using XML fallback")
            parseAvtLastChangeFallback(xml)
        }
    }

    /** Regex-based fallback for LastChange XML when the jUPnP parser can't find
     *  the XSD schema files. Extracts TransportState and CurrentTrackMetaData
     *  directly from the XML attributes. */
    private fun parseAvtLastChangeFallback(xml: String) {
        runCatching {
            var cur = _state.value
            // TransportState: <TransportState val="PLAYING"/>
            val tsMatch = Regex("<TransportState\\s+val=\"([^\"]+)\"").find(xml)
            if (tsMatch != null) {
                val ts = tsMatch.groupValues[1]
                val stopped = ts == "STOPPED" || ts == "NO_MEDIA_PRESENT"
                cur = cur.copy(
                    isPlaying = ts == "PLAYING",
                    isStopped = stopped,
                    isBuffering = ts == "TRANSITIONING",
                )
                if (stopped && shouldAutoAdvance()) {
                    scope.launch { autoAdvance() } // Maintainer, 2026-10-29: honours repeat
                } else if (stopped) {
                    userRequestedStop = true   // a stop from anywhere is a stop
                }
            }
            // CurrentTrackMetaData: <CurrentTrackMetaData val="...encoded DIDL..."/>
            val metaMatch = Regex("<CurrentTrackMetaData\\s+val=\"([^\"]*?)\"\\s*/>").find(xml)
            if (metaMatch != null) {
                val encoded = metaMatch.groupValues[1]
                if (encoded.isNotBlank() && encoded != "NOT_IMPLEMENTED") {
                    val decoded = encoded
                        .replace("&lt;", "<").replace("&gt;", ">")
                        .replace("&amp;", "&").replace("&quot;", "\"")
                        .replace("&apos;", "'")
                    val uriHint = cur.current?.resourceUrl.orEmpty()
                    DidlLiteCodec.decodeFirstTrack(decoded, uriHint)?.let {
                        cur = cur.copy(current = it)
                    }
                }
            }
            // CurrentTrackDuration: <CurrentTrackDuration val="0:03:45"/>
            val durMatch = Regex("<CurrentTrackDuration\\s+val=\"([^\"]+)\"").find(xml)
            if (durMatch != null) {
                parseHmsToDuration(durMatch.groupValues[1])?.let {
                    cur = cur.copy(duration = it)
                }
            }
            _state.value = cur
        }.onFailure { log("AVT fallback parser also failed: ${it.message}") }
    }

    private fun parseRcLastChange(xml: String) {
        runCatching {
            val event = rcParser.parse(xml) ?: return@runCatching
            val instance = event.instanceIDs?.firstOrNull() ?: return@runCatching
            val values = instance.values ?: return@runCatching
            var cur = _state.value
            values.forEach { v: EventedValue<*> ->
                when (v) {
                    is RenderingControlVariable.Volume -> {
                        // ChannelVolume getters: channel + volume (Integer).
                        val cv = v.value
                        if (cv?.channel == org.jupnp.support.model.Channel.Master) {
                            cv.volume?.toInt()?.coerceIn(0, 100)?.let { vol ->
                                cur = cur.copy(volumePercent = vol)
                            }
                        }
                    }
                    is RenderingControlVariable.Mute -> {
                        // We don't track mute in PlaybackState; volume = 0 conveys it.
                    }
                    else -> { /* Loudness, PresetNameList, etc. — ignored */ }
                }
            }
            _state.value = cur
        }.onFailure { log("parseRcLastChange threw: ${it.message}") }
    }

    // ---- Position poller ----------------------------------------------------

    /** Poll GetTransportInfo (1 s playing / 10 s otherwise) and GetPositionInfo
     *  while playing. Also the auto-advance path when GENA events don't arrive. */
    private fun startPositionPoller() {
        positionPollerJob?.cancel()
        positionPollerJob = scope.launch {
            while (true) {
                val device = findRemoteDevice()
                if (device != null) {
                    // Poll transport state — critical when GENA subscriptions
                    // fail (missing XSD schema files on Android).
                    runCatching {
                        val info = invokeAvtAction(device, "GetTransportInfo", emptyMap())
                        val ts = info?.get("CurrentTransportState").orEmpty()
                        val wasStopped = _state.value.isStopped
                        val nowStopped = ts == "STOPPED" || ts == "NO_MEDIA_PRESENT"
                        _state.value = _state.value.copy(
                            isPlaying = ts == "PLAYING",
                            isStopped = nowStopped,
                            isBuffering = ts == "TRANSITIONING",
                        )
                        // Auto-advance only when OUR track reached its end.
                        if (nowStopped && !wasStopped && shouldAutoAdvance()) {
                            autoAdvance() // Maintainer, 2026-10-29: honours repeat
                        } else if (nowStopped && !wasStopped) {
                            userRequestedStop = true   // a stop from anywhere is a stop
                        }
                    }
                    // Poll position when playing; remember it so the end-of-
                    // track test below has something to compare against.
                    if (_state.value.isPlaying) {
                        runCatching {
                            val out = invokeAvtAction(device, "GetPositionInfo", emptyMap())
                            val relTime = out?.get("RelTime").orEmpty()
                            parseHmsToDuration(relTime)?.let {
                                _state.value = _state.value.copy(position = it)
                                lastSeenPosition = it
                            }
                            // Also grab duration if we don't have it
                            if (_state.value.duration == kotlin.time.Duration.ZERO) {
                                val durStr = out?.get("TrackDuration").orEmpty()
                                parseHmsToDuration(durStr)?.let {
                                    _state.value = _state.value.copy(duration = it)
                                }
                            }
                        }
                    }
                }
                // design decision 2026-09-02: once a second while playing,
                // every ten seconds while paused/stopped — still finds out
                // when someone presses Play on the speaker itself, without
                // sixty requests a minute for nothing.
                delay(if (_state.value.isPlaying) 1_000 else 10_000)
            }
        }
    }

    /** Position seen at the last poll while playing; used to decide whether
     *  a STOPPED means "our track finished" or "someone stopped it". */
    @Volatile private var lastSeenPosition: Duration = Duration.ZERO

    /** design decision 2026-09-02: the phone advances to the next song only
     *  when its OWN track played to the end. Any other stop — the speaker's
     *  buttons, another app — is a stop, and the phone sends nothing.
     *  "Reached the end": the last position we saw was within a few seconds
     *  of the track's duration (or the duration is unknown and we saw the
     *  track play for at least as long as its declared length is absent —
     *  in which case we cannot tell, and we do NOT advance). */
    private fun shouldAutoAdvance(): Boolean {
        if (userRequestedStop) return false
        if (System.currentTimeMillis() - lastTrackSentAt <= 5000) return false
        // Maintainer, 2026-10-29: "is there a next track" now follows repeat.
        if (nextAutoIndex() == null) return false
        val dur = _state.value.duration
        if (dur <= Duration.ZERO) return false
        val remaining = dur - lastSeenPosition
        return remaining <= 3.seconds
    }

    /** Maintainer, 2026-10-29: where playback goes when OUR track ended —
     *  repeat ONE replays it, repeat ALL wraps to the start, otherwise the
     *  next track or nothing. Shuffle isn't applied (plays in order). */
    private fun nextAutoIndex(): Int? = when {
        localQueue.isEmpty() -> null
        _state.value.repeatMode == RepeatMode.ONE -> localQueueIndex
        localQueueIndex < localQueue.size - 1 -> localQueueIndex + 1
        _state.value.repeatMode == RepeatMode.ALL -> 0
        else -> null
    }

    /** Maintainer, 2026-10-29: the auto-advance itself (was a bare +1). */
    private suspend fun autoAdvance() {
        val next = nextAutoIndex() ?: return
        sendTrackAtIndex(next, autoplay = true)
    }

    // ---- jUPnP action plumbing ----------------------------------------------

    /** Invoke a named action on the remote device's AVTransport service. Returns the
     *  output args by name, or null on failure. Suspends until the action completes. */
    private suspend fun invokeAvtAction(
        actionName: String,
        args: Map<String, String> = emptyMap(),
    ): Map<String, String>? {
        val device = findRemoteDevice() ?: return null
        return invokeAvtAction(device, actionName, args)
    }

    private suspend fun invokeAvtAction(
        device: RemoteDevice,
        actionName: String,
        args: Map<String, String>,
    ): Map<String, String>? {
        val service = device.findAvtService() ?: return null
        return invokeOnService(service, actionName, args)
    }

    private suspend fun invokeRcAction(
        actionName: String,
        args: Map<String, String> = emptyMap(),
    ): Map<String, String>? {
        val device = findRemoteDevice() ?: return null
        return invokeRcAction(device, actionName, args)
    }

    private suspend fun invokeRcAction(
        device: RemoteDevice,
        actionName: String,
        args: Map<String, String>,
    ): Map<String, String>? {
        val service = device.findRcService() ?: return null
        return invokeOnService(service, actionName, args)
    }

    /** Core suspend-wrap of jUPnP's callback-based execute(). Always includes
     *  `InstanceID=0` as the first argument since Earhorn is single-instance. */
    private suspend fun invokeOnService(
        service: RemoteService,
        actionName: String,
        args: Map<String, String>,
    ): Map<String, String>? {
        val action = service.getAction(actionName) ?: run {
            log("Service has no '$actionName' action")
            return null
        }
        val controlPoint = holder.controlPoint ?: return null

        return suspendCancellableCoroutine { cont ->
            val invocation = ActionInvocation(action)
            // InstanceID is the conventional first arg for both AVT and RC.
            // Renderers that don't support multi-instance ignore non-zero IDs.
            runCatching { invocation.setInput("InstanceID", "0") }
            args.forEach { (k, v) ->
                runCatching { invocation.setInput(k, v) }
            }

            val callback = object : ActionCallback(invocation) {
                override fun success(inv: ActionInvocation<*>?) {
                    val outputs = inv?.toOutputMap().orEmpty()
                    if (cont.isActive) cont.resume(outputs)
                }
                override fun failure(
                    inv: ActionInvocation<*>?,
                    operation: UpnpResponse?,
                    defaultMsg: String?,
                ) {
                    val cause = inv?.failure as? ActionException ?: ActionException(
                        org.jupnp.model.types.ErrorCode.ACTION_FAILED,
                        defaultMsg ?: "Unknown error"
                    )
                    log("Action $actionName failed: ${cause.message}")
                    if (cont.isActive) cont.resume(null)
                }
            }
            controlPoint.execute(callback)
        }
    }

    /** Convert an [ActionInvocation]'s output arguments into a name→string-value map.
     *  Function (not property) to avoid colliding with whatever name jUPnP may already
     *  expose on the class — extension properties don't override member properties,
     *  and the compiler errors get confusing fast. */
    private fun ActionInvocation<*>.toOutputMap(): Map<String, String> =
        output.orEmpty().associate { it.argument.name to (it.value?.toString().orEmpty()) }

    // ---- Helpers ------------------------------------------------------------

    private fun findRemoteDevice(): RemoteDevice? = holder.findDevice(target.udn)

    private fun RemoteDevice.findAvtService(): RemoteService? =
        findService(UDAServiceType("AVTransport", 1)) as? RemoteService

    private fun RemoteDevice.findRcService(): RemoteService? =
        findService(UDAServiceType("RenderingControl", 1)) as? RemoteService

    private fun formatDurationAsUpnpClock(duration: Duration): String {
        val totalMs = duration.inWholeMilliseconds.coerceAtLeast(0)
        val ms = totalMs % 1000
        val total = totalMs / 1000
        val h = total / 3600
        val m = (total % 3600) / 60
        val s = total % 60
        return "%d:%02d:%02d.%03d".format(h, m, s, ms)
    }

    private fun parseHmsToDuration(clock: String?): Duration? {
        if (clock.isNullOrBlank() || clock == "NOT_IMPLEMENTED") return null
        val parts = clock.split(":")
        if (parts.size != 3) return null
        val h = parts[0].toLongOrNull() ?: return null
        val m = parts[1].toLongOrNull() ?: return null
        val s = parts[2].toDoubleOrNull() ?: return null
        val ms = ((h * 3600 + m * 60) * 1000 + (s * 1000).toLong())
        return ms.milliseconds
    }

    private fun log(msg: String) {
        android.util.Log.i("EarhornUpnp", "[RemoteUpnpRenderer/${target.friendlyName}] $msg")
    }
}