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

CastObserverApi.kt

package dev.stave.cast.raw

import dev.stave.domain.renderer.PlaybackState
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration

/**
 * Platform-agnostic surface for raw-cast observer mode — the "follow a
 * Chromecast group" capability that lets the desktop UI render what some other
 * sender (typically the phone) is playing on a CCA group.
 *
 * Desktop and Android both have concrete implementations:
 *  - Desktop: `dev.stave.cast.raw.RawCastObserver` (in `desktopMain`),
 *    delegates discovery to `CastIntegration`.
 *  - Android: the Hilt-injected `RawCastObserver` (in `androidMain`), uses
 *    `NsdManager` for discovery.
 *
 * This interface is what `NowPlayingViewModel` depends on, so the VM stays in
 * `jvmCommonMain` and works on both targets without dragging the platform-
 * specific discovery wiring up to the shared layer. Surfaces tied to a
 * platform-specific device type (like the picker's `observe(device)` call) are
 * NOT on the interface — those live in the platform-specific UI layer that
 * already has the concrete observer in hand.
 */
interface CastObserverApi {
    /** True while connected to a Cast device in observer mode. UI's `tvTrack`
     *  / `tvState` resolution flips on this flag. */
    val observing: StateFlow<Boolean>

    /** Domain [PlaybackState] mapped from the observed `MEDIA_STATUS` stream.
     *  Shape is identical to `Renderer.state` — UI surfaces can render either
     *  one without branching on which kind of source it is. */
    val playbackState: StateFlow<PlaybackState>

    /** Friendly name of the device being followed, or null. Picker uses this to
     *  show the active follow target alongside the device list. */
    val connectedDeviceName: StateFlow<String?>

    /** Positive evidence the followed session is alive: the receiver reports
     *  a media session that is not IDLE, its position isn't frozen past the
     *  zombie latch, and statuses haven't gone silent. Raw, NOT debounced —
     *  it drops to false for under a second at every track boundary, so
     *  consumers that paint from it must wait before believing "dead"
     *  (Android parity, 2026-09-03: 20 s). False when not observing. */
    val sessionLive: StateFlow<Boolean>

    suspend fun play()
    suspend fun pause()
    suspend fun stop()
    suspend fun next()
    suspend fun previous()
    suspend fun seek(position: Duration)
    suspend fun setVolume(percent: Int)
    /** Repeat / shuffle of the OBSERVED session (2026-09-04, the maintainer's
     *  ruling: while following, these act on the group, like every other
     *  transport control). */
    suspend fun setRepeatMode(mode: dev.stave.domain.renderer.RepeatMode)
    suspend fun setShuffle(on: Boolean)

    /**
     * Jump the observed session to the queue row at [index] (an index into
     * [playbackState].queue). Position-only — QUEUE_UPDATE with currentItemId
     * never alters queue contents, so it's safe for a follower to send: the
     * driving sender's index tracker follows along via its own status
     * callbacks. This is the ONLY queue operation a follower may perform;
     * editing (remove/reorder/insert) is deliberately prohibited because the
     * sender's local queue is authoritative and would silently clobber (and
     * desync from) any follower-side edit on its next reload.
     */
    suspend fun jumpToQueueItem(index: Int)

    /** Force a status refresh. Implementations may also do this on a timer; this
     *  is the manual trigger for surfaces that want fresh data right now (e.g.
     *  after the window regains focus). */
    suspend fun requestStatusRefresh()

    /** Stop observing. Idempotent. */
    fun disconnect()
}