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

LocalPlaybackGate.kt

package dev.stave.playback

import dev.stave.cast.raw.CastObserverApi
import dev.stave.ui.events.UiEvents
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

/**
 * "Stop following and play here?" — ported from Android on the maintainer's
 * answer 4 (2026-09-04). Every action that would start local audio while the
 * desktop is following the group parks here until the person answers. Yes:
 * stop following, run the action. No: nothing happens, the follow continues.
 * When not following, the action runs at once.
 *
 * Starters that go through here (audit, as on Android 2026-09-02): Party /
 * random tracks, playlists, Now Playing's play/enqueue-and-play, and every
 * Browse starter — play a track in context, Play all on a container, the
 * per-genre "Play 42 random…", and Play next into an EMPTY queue (that
 * starts audio). Deliberately not gated: Add to queue, and Play next onto a
 * non-empty queue — they don't start audio.
 */
class LocalPlaybackGate(
    private val rawCast: CastObserverApi,
    private val uiEvents: UiEvents? = null,
) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private var pending: (suspend () -> Unit)? = null

    /** Runs after the parked/immediate action has actually run (Android's
     *  hook so a screen can navigate only when something played; I.69 /
     *  Android review N.31). Dropped with the action on [dismiss]. */
    private var pendingOnRan: (() -> Unit)? = null

    private val _confirmVisible = MutableStateFlow(false)
    val confirmVisible: StateFlow<Boolean> = _confirmVisible.asStateFlow()

    // Parameter order matters (Android note): Kotlin gives a trailing lambda
    // to the LAST parameter, so `request { … }` is the action and
    // `request(onRan) { … }` adds the completion.
    fun request(onRan: (() -> Unit)? = null, action: suspend () -> Unit) {
        if (!rawCast.observing.value) {
            run(action, onRan)
            return
        }
        pending = action
        pendingOnRan = onRan
        _confirmVisible.value = true
    }

    fun confirm() {
        _confirmVisible.value = false
        rawCast.disconnect()
        val action = pending
        val onRan = pendingOnRan
        pending = null
        pendingOnRan = null
        action?.let { run(it, onRan) }
    }

    fun dismiss() {
        pending = null
        pendingOnRan = null
        _confirmVisible.value = false
    }

    private fun run(action: suspend () -> Unit, onRan: (() -> Unit)?) {
        scope.launch {
            try {
                action()
            } catch (c: CancellationException) {
                throw c
            } catch (t: Throwable) {
                // Was a silent printStackTrace (Team 5 V12): say something.
                System.err.println("[Earhorn/Gate] gated action threw (${t.javaClass.simpleName}):")
                t.printStackTrace(System.err)
                uiEvents?.post(MSG_FAILED)
                return@launch
            }
            runOnRan(onRan)
        }
    }

    /** Run [onRan], logging and dropping a failure — this scope outlives the
     *  UI, so the callback may touch a screen that has since gone (Android,
     *  Android parity, 2026-10-29). */
    fun runOnRan(onRan: (() -> Unit)?) {
        if (onRan == null) return
        try {
            onRan()
        } catch (t: Throwable) {
            System.err.println("[Earhorn/Gate] onRan failed: $t")
        }
    }

    private companion object {
        const val MSG_FAILED = "Something went wrong — couldn't start playback."
    }
}