170 lines
6.1 KiB
Swift
170 lines
6.1 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// Owns the configured targets, polls them on a single-flight loop, keeps the
|
|
/// last-known state per target, and routes user commands. Observable + main-actor
|
|
/// so SwiftUI binds directly.
|
|
@Observable
|
|
@MainActor
|
|
public final class PlayerController {
|
|
|
|
public struct Snapshot: Sendable, Equatable {
|
|
public var state: ConnectionState = .checking
|
|
public var status: PlaybackStatus = .idle
|
|
}
|
|
|
|
public private(set) var targets: [any PlayerTarget] = []
|
|
public var activeID: String = ""
|
|
public private(set) var snapshots: [String: Snapshot] = [:]
|
|
/// Releases of the active target's current show (empty unless switchable).
|
|
public private(set) var releases: [Release] = []
|
|
/// Host load of the active target (nil unless it reports stats), plus a
|
|
/// rolling window of decode %CPU so the UI can chart the drop on a switch.
|
|
public private(set) var hostStats: HostStats?
|
|
public private(set) var decodeHistory: [Double] = []
|
|
|
|
private var pollTask: Task<Void, Never>?
|
|
private var statsTask: Task<Void, Never>?
|
|
private var polling = false // single-flight guard for status
|
|
private var tickCount = 0
|
|
private var lastReleaseKey: String?
|
|
private var statsTarget: String?
|
|
private let historyCap = 48
|
|
|
|
public init() { reload() }
|
|
|
|
public var active: (any PlayerTarget)? { targets.first { $0.id == activeID } }
|
|
|
|
public func snapshot(_ id: String) -> Snapshot { snapshots[id] ?? Snapshot() }
|
|
public var activeSnapshot: Snapshot { snapshot(activeID) }
|
|
|
|
/// (Re)load hosts.json and rebuild targets, preserving the active selection
|
|
/// and any last-known snapshots that still apply.
|
|
public func reload() {
|
|
let cfg = HostsConfig.loadOrSeed()
|
|
targets = cfg.hosts.compactMap(Self.makeTarget)
|
|
if active == nil { activeID = targets.first?.id ?? "" }
|
|
var next: [String: Snapshot] = [:]
|
|
for t in targets { next[t.id] = snapshots[t.id] ?? Snapshot() }
|
|
snapshots = next
|
|
}
|
|
|
|
static func makeTarget(_ h: HostConfig) -> (any PlayerTarget)? {
|
|
switch h.kind {
|
|
case .vlc:
|
|
guard let v = h.vlc else { return nil }
|
|
return VLCTarget(id: h.id, name: h.name, host: v.host, port: v.port,
|
|
password: VLCConfig.password())
|
|
case .blacktv:
|
|
guard let s = h.ssh else { return nil }
|
|
return BlackTVTarget(id: h.id, name: h.name, endpoints: s.endpoints, bin: s.bin)
|
|
}
|
|
}
|
|
|
|
public func start() {
|
|
pollTask?.cancel()
|
|
pollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
await self?.tick()
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
}
|
|
}
|
|
// Stats run on their own cadence so the 0.25s decode sample never slows
|
|
// the transport status poll (ControlMaster multiplexes the two channels).
|
|
statsTask?.cancel()
|
|
statsTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
await self?.refreshStats()
|
|
try? await Task.sleep(for: .seconds(2))
|
|
}
|
|
}
|
|
}
|
|
|
|
public func stop() { pollTask?.cancel(); statsTask?.cancel() }
|
|
|
|
public func refreshStats() async {
|
|
guard let p = active as? HostStatsProvider else {
|
|
if hostStats != nil { hostStats = nil }
|
|
if !decodeHistory.isEmpty { decodeHistory = [] }
|
|
statsTarget = nil
|
|
return
|
|
}
|
|
if statsTarget != activeID { statsTarget = activeID; decodeHistory = [] }
|
|
let s = await p.stats()
|
|
hostStats = s
|
|
if let cpu = s?.mpv_cpu {
|
|
decodeHistory.append(cpu)
|
|
if decodeHistory.count > historyCap {
|
|
decodeHistory.removeFirst(decodeHistory.count - historyCap)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Poll the active target every tick; the others every 4th (their state only
|
|
/// drives the picker's reachability dot).
|
|
private func tick() async {
|
|
guard !polling else { return }
|
|
polling = true
|
|
defer { polling = false }
|
|
tickCount += 1
|
|
for t in targets where t.id == activeID || tickCount % 4 == 0 {
|
|
apply(await t.poll(), to: t.id)
|
|
}
|
|
}
|
|
|
|
/// Send a command to the active target, then immediately refresh just it so
|
|
/// the UI reflects the change without waiting for the next tick.
|
|
public func command(_ op: @escaping (any PlayerTarget) async -> Void) {
|
|
guard let active else { return }
|
|
Task {
|
|
await op(active)
|
|
await refreshActive()
|
|
}
|
|
}
|
|
|
|
public func refreshActive() async {
|
|
guard let active, !polling else { return }
|
|
polling = true
|
|
defer { polling = false }
|
|
apply(await active.poll(), to: active.id)
|
|
}
|
|
|
|
// MARK: Quality / release switching
|
|
|
|
/// Refetch the active target's releases only when the show/episode changed
|
|
/// (cheap: one SSH call per episode boundary, not per poll).
|
|
public func refreshReleases() async {
|
|
guard let q = active as? QualitySwitchable else {
|
|
if !releases.isEmpty { releases = [] }
|
|
lastReleaseKey = nil
|
|
return
|
|
}
|
|
let key = "\(activeID)|\(activeSnapshot.status.title ?? "")"
|
|
guard key != lastReleaseKey else { return }
|
|
lastReleaseKey = key
|
|
releases = await q.releases()
|
|
}
|
|
|
|
public func switchRelease(_ id: String) {
|
|
guard let q = active as? QualitySwitchable else { return }
|
|
Task {
|
|
await q.switchRelease(id)
|
|
lastReleaseKey = nil // force a refetch (current flag moved)
|
|
await refreshActive()
|
|
await refreshReleases()
|
|
}
|
|
}
|
|
|
|
/// Keep-last-known: on unreachable we flip the badge but never wipe the
|
|
/// previously-known playback state.
|
|
private func apply(_ r: PollResult, to id: String) {
|
|
var snap = snapshots[id] ?? Snapshot()
|
|
if r.reachable {
|
|
snap.state = .connected
|
|
if let s = r.status { snap.status = s }
|
|
} else {
|
|
snap.state = .unreachable
|
|
}
|
|
snapshots[id] = snap
|
|
}
|
|
}
|