2026-06-07 20:24:55 -07:00
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
/// Live connection state of a target, kept separate from playback so a single
|
|
|
|
|
/// dropped poll renders as "unreachable (last known …)" rather than wiping state.
|
|
|
|
|
public enum ConnectionState: String, Sendable, Equatable {
|
|
|
|
|
case checking, connected, unreachable
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Unified playback state across every target. Volume is normalized to a
|
|
|
|
|
/// percentage (100 = normal) regardless of the backend's native scale.
|
2026-06-08 22:04:22 -07:00
|
|
|
public struct PlaybackStatus: Equatable, Sendable, Codable {
|
2026-06-07 20:24:55 -07:00
|
|
|
public var playing: Bool
|
|
|
|
|
public var paused: Bool?
|
|
|
|
|
public var title: String?
|
|
|
|
|
public var volume: Double? // percent (100 = normal)
|
|
|
|
|
public var position: Double? // seconds
|
|
|
|
|
public var duration: Double? // seconds
|
|
|
|
|
public var playlistPos: Int?
|
|
|
|
|
public var playlistCount: Int?
|
|
|
|
|
|
|
|
|
|
public init(playing: Bool, paused: Bool? = nil, title: String? = nil,
|
|
|
|
|
volume: Double? = nil, position: Double? = nil, duration: Double? = nil,
|
|
|
|
|
playlistPos: Int? = nil, playlistCount: Int? = nil) {
|
|
|
|
|
self.playing = playing; self.paused = paused; self.title = title
|
|
|
|
|
self.volume = volume; self.position = position; self.duration = duration
|
|
|
|
|
self.playlistPos = playlistPos; self.playlistCount = playlistCount
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static let idle = PlaybackStatus(playing: false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Result of polling a target: reachability and (if reachable) the playback
|
|
|
|
|
/// state. `status == nil` while reachable means "reachable but no clear state".
|
|
|
|
|
public struct PollResult: Sendable {
|
|
|
|
|
public var reachable: Bool
|
|
|
|
|
public var status: PlaybackStatus?
|
|
|
|
|
public init(reachable: Bool, status: PlaybackStatus?) {
|
|
|
|
|
self.reachable = reachable; self.status = status
|
|
|
|
|
}
|
|
|
|
|
public static let unreachable = PollResult(reachable: false, status: nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A place we can send playback to. Reference type so an implementation can hold
|
|
|
|
|
/// connection state (e.g. a pinned SSH endpoint). Verbs translate onto an
|
|
|
|
|
/// existing backend — the playback intelligence lives there, not here.
|
|
|
|
|
public protocol PlayerTarget: AnyObject {
|
|
|
|
|
var id: String { get }
|
|
|
|
|
var name: String { get }
|
|
|
|
|
var kind: HostKind { get }
|
|
|
|
|
var detail: String { get } // human-readable endpoint, for the Hosts view
|
|
|
|
|
var volumeScale: Int { get } // max value for the volume slider (percent)
|
|
|
|
|
|
|
|
|
|
func poll() async -> PollResult
|
|
|
|
|
func playPause() async // toggle
|
|
|
|
|
func resume() async // ensure playing
|
|
|
|
|
func setVolume(_ percent: Int) async
|
|
|
|
|
func seek(relative seconds: Int) async
|
2026-06-08 22:04:22 -07:00
|
|
|
func seek(toSeconds seconds: Int) async // absolute — for the scrubber + resume
|
2026-06-07 20:24:55 -07:00
|
|
|
func next() async
|
|
|
|
|
func previous() async
|
|
|
|
|
func stop() async
|
|
|
|
|
}
|
2026-06-09 21:23:36 -07:00
|
|
|
|
|
|
|
|
/// A target whose host-side player service can be restarted in place (black:
|
|
|
|
|
/// relaunch the root-owned mpv unit when it hangs or its socket goes stale,
|
|
|
|
|
/// resuming what was playing). The restart is delegated to a per-host command,
|
|
|
|
|
/// so conformance alone isn't enough — `canRestartService` reflects whether
|
|
|
|
|
/// that command is actually configured. Drives the Devices tab action.
|
|
|
|
|
public protocol ServiceRestartable: AnyObject {
|
|
|
|
|
var canRestartService: Bool { get }
|
|
|
|
|
@discardableResult func restartService() async -> Bool
|
|
|
|
|
}
|
2026-06-09 21:37:34 -07:00
|
|
|
|
|
|
|
|
/// A target whose host-side helper can be updated in place from the app: the
|
|
|
|
|
/// repo's vendored script is pushed over the target's own channel and installed
|
|
|
|
|
/// atop the deployed bin, verified by content hash. Pairs with
|
|
|
|
|
/// `ServiceRestartable` for the full self-heal: update, then restart.
|
|
|
|
|
public protocol ServiceUpdatable: AnyObject {
|
|
|
|
|
var canUpdateService: Bool { get }
|
|
|
|
|
@discardableResult func updateService() async -> Bool
|
|
|
|
|
}
|