tv-anarchy/Sources/PlumTVCore/PlayerController.swift
Natalie ba1a3f24b8 feat(plum-tv): add cpu load and history chart to player view
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-07 20:52:42 -07:00

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
}
}