tv-anarchy/Sources/PlumTVCore/PlayerController.swift
Natalie 10f2abd022 feat(plum-tv): add quality picker UI and switching support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-07 20:41:37 -07:00

136 lines
4.7 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] = []
private var pollTask: Task<Void, Never>?
private var polling = false // single-flight guard
private var tickCount = 0
private var lastReleaseKey: String?
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))
}
}
}
public func stop() { pollTask?.cancel() }
/// 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
}
}