tv-anarchy/Sources/TVAnarchyCore/PlayerController.swift

573 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] = []
/// Tracks of the active target's current file (empty unless TrackSelectable).
public private(set) var audioTracks: [MediaTrack] = []
public private(set) var subtitleTracks: [MediaTrack] = []
/// What's currently launched, so the Sub/Dub choice keys to the right series.
public private(set) var activeSeries: String?
public private(set) var activeCategory: String?
/// Transient user-facing note (launch failures, routing). UI shows + clears it.
public var actionMessage: String?
private var trackPrefs = TrackPreferenceStore.load()
private var pollTask: Task<Void, Never>?
private var statsTask: Task<Void, Never>?
/// Forwards the Mac's system transport (media keys / Control Center) to the
/// active target and publishes Now Playing. Gated by `forwardMediaKeys`.
private let nowPlaying = NowPlayingController()
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
private var statusCache: [String: PlayerStatusCache.Entry] = [:]
private var lastCacheWrite = Date.distantPast
public init() {
statusCache = PlayerStatusCache.load()
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) }
/// The default playback target: the TV (mpv/black) if present, else first.
private var defaultTargetID: String {
(targets.first { $0.kind == .mpvIPC || $0.kind == .blacktv } ?? targets.first)?.id ?? ""
}
/// (Re)load hosts.json and rebuild targets. The active host always defaults to
/// the TV (not persisted a stale persisted VLC was hijacking playback); the
/// session pick is preserved across an in-session reload.
public func reload() {
let cfg = DevicesConfig.loadOrSeed()
targets = cfg.devices.compactMap(Self.makeTarget)
if active == nil { activeID = defaultTargetID }
var next: [String: Snapshot] = [:]
for t in targets {
if let existing = snapshots[t.id] {
next[t.id] = existing // live state from this session
} else if let cached = statusCache[t.id] {
next[t.id] = Snapshot(state: .checking, status: cached.status) // cold-start cache
} else {
next[t.id] = Snapshot()
}
}
snapshots = next
}
static func makeTarget(_ h: DeviceConfig) -> (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:
// Legacy schema auto-migrate to the generic mpv-IPC target, deriving
// the delegated commands from the old `bin`. (BlackTVTarget retired.)
guard let s = h.ssh else { return nil }
return MpvTarget(id: h.id, name: h.name,
mpv: MpvConn(endpoints: s.endpoints),
commands: CommandsConfig.blackTVDefaults(bin: s.bin))
case .mpvIPC:
guard let m = h.mpv else { return nil }
return MpvTarget(id: h.id, name: h.name, mpv: m, commands: h.commands)
case .quicktime:
return QuickTimeTarget(id: h.id, name: h.name)
}
}
// MARK: Local player choice (Setup)
/// The configured local player kind (vlc/quicktime), if any.
public var localPlayerKind: HostKind? { DevicesConfig.loadOrSeed().localPlayerKind }
/// Swap the local player to `kind` (rewrites the local host in hosts.json) and
/// reload so the change takes effect immediately.
public func setLocalPlayer(_ kind: HostKind) {
var cfg = DevicesConfig.loadOrSeed()
cfg.setLocalPlayer(kind)
try? cfg.save()
reload()
}
// MARK: Host configuration (CRUD, persisted to hosts.json)
/// The on-disk host configs (for the editor distinct from live `targets`).
public var editableDevices: [DeviceConfig] { DevicesConfig.loadOrSeed().devices }
public func saveDevices(_ hosts: [DeviceConfig]) {
try? DevicesConfig(devices: hosts).save()
Log.info("saved hosts config (\(hosts.count): \(hosts.map(\.name).joined(separator: ", ")))")
reload()
}
/// Add a new host or replace the existing one with the same id.
public func upsertDevice(_ host: DeviceConfig) {
var hosts = DevicesConfig.loadOrSeed().devices
if let i = hosts.firstIndex(where: { $0.id == host.id }) { hosts[i] = host }
else { hosts.append(host) }
saveDevices(hosts)
}
public func deleteDevice(_ id: String) {
saveDevices(DevicesConfig.loadOrSeed().devices.filter { $0.id != id })
}
/// Re-seed the default set (plum VLC + black mpv-ipc with LAN+overlay endpoints).
public func resetDevicesToDefault() { saveDevices(DevicesConfig.seeded().devices) }
/// True while the Player tab is on screen. Off-tab we still poll the active
/// target slowly so the HostSelector dots stay fresh and an armed sleep
/// timer's end-of-episode check keeps running; but the fast 1.5s transport
/// cadence and the per-2s host-stats sampling (only charted on the Player tab)
/// are gated to it, so we stop hammering black while the user is elsewhere.
public var detailed = false { didSet { if detailed != oldValue { start() } } }
public func start() {
applyMediaKeyForwarding()
pollTask?.cancel()
pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.tick()
let secs = (self?.detailed ?? false) ? 1.5 : 10
try? await Task.sleep(for: .seconds(secs))
}
}
// Stats (decode %CPU chart) only matter on the Player tab don't sample
// them otherwise. Separate cadence so the sample never slows the transport
// poll (ControlMaster multiplexes the two channels).
statsTask?.cancel()
guard detailed else { hostStats = nil; decodeHistory = []; return }
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(); nowPlaying.disable() }
/// Enable/disable system media-key forwarding per the current setting. Called
/// on every `start()` (idempotent), so flipping the setting takes effect live.
/// Handlers route to the active target via the existing `command` path.
public func applyMediaKeyForwarding() {
guard SettingsStore.load().forwardMediaKeys else { nowPlaying.disable(); return }
nowPlaying.enable(.init(
toggle: { [weak self] in self?.command { await $0.playPause() } },
play: { [weak self] in self?.command { await $0.resume() } },
pause: { [weak self] in
guard let self, NowPlayingController.isActivelyPlaying(self.activeSnapshot.status) else { return }
self.command { await $0.playPause() }
},
next: { [weak self] in self?.command { await $0.next() } },
previous: { [weak self] in self?.command { await $0.previous() } },
seek: { [weak self] secs in self?.command { await $0.seek(toSeconds: Int(secs)) } }))
}
/// Push the active target's state to Now Playing / the system transport.
private func pushNowPlaying() {
guard nowPlaying.isEnabled else { return }
let s = activeSnapshot.status
nowPlaying.update(
title: s.title, posterPath: nil,
position: s.position, duration: s.duration,
playing: NowPlayingController.isActivelyPlaying(s),
canStep: NowPlayingController.canStep(playlistCount: s.playlistCount,
isEnqueueable: active is Enqueueable))
}
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)
}
checkEndOfEpisode()
pushNowPlaying()
}
/// 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()
}
}
/// Like `command`, but awaitable the caller can hold an optimistic UI value
/// until the change is both sent and re-polled (the volume slider uses this to
/// avoid snapping the thumb back to a stale value during the round-trip).
public func commandAwait(_ op: @escaping (any PlayerTarget) async -> Void) async {
guard let active else { return }
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: Library launch
/// The active target's backend kind, so callers can build the right
/// LaunchRequest (black resolves by name; VLC needs a file path).
public var activeKind: HostKind? { active?.kind }
/// Whether the active target can play a multi-item queue (the unified
/// play-from-here feature falls back to a single launch when it can't).
public var canEnqueue: Bool { active is Enqueueable }
/// Set the launched-series context (so the Sub/Dub track choice keys to the
/// right series) when playback is driven by the queue rather than `launch`.
public func setActiveContext(series: String?, category: String?) {
activeSeries = series; activeCategory = category
}
/// THE single source of truth for where playback goes. Set by the shared
/// HostSelector (Player + Library use the same control); Library and Player
/// both launch to whatever this is. Defaults to the TV (see `reload`).
public func setActive(_ id: String) {
if targets.contains(where: { $0.id == id }) { activeID = id }
}
/// Surface a transient note to the user (e.g. why a click did nothing).
public func note(_ message: String?) { actionMessage = message }
/// Begin playback of a library request on the active target, then refresh it.
/// `series`/`category` let the track preference (sub/dub) resolve and apply
/// once the file is loaded; `resumeSeconds` seeks VLC/QuickTime to the saved
/// position (black resumes itself). Surfaces a note on failure instead of
/// silently doing nothing.
/// Fire a multi-item play queue at the active host (replaces its playlist and
/// starts the first). Surfaces success/failure via `actionMessage`, like launch.
public func enqueuePlaylist(_ paths: [String], resumeFirst: Double? = nil) {
guard let target = active as? Enqueueable else {
actionMessage = "\(active?.name ?? "This host") cant play a queue"; return
}
guard !paths.isEmpty else { actionMessage = "Queue is empty"; return }
actionMessage = nil
let hostName = active?.name ?? "?"
Log.info("enqueue \(paths.count) on \(hostName)")
Task {
let ok = await target.enqueue(paths, replace: true)
// Resume the first item where it was left off (the rest start at 0).
if ok, let r = resumeFirst, r > 1 { await active?.seek(toSeconds: Int(r)) }
await refreshActive()
actionMessage = ok ? "Queued \(paths.count) on \(hostName)"
: "Couldnt queue on \(hostName) — host unreachable"
if !ok { Log.error("enqueue failed on \(hostName)") }
}
}
public func launch(_ request: LaunchRequest, series: String? = nil,
category: String? = nil, resumeSeconds: Double? = nil) {
guard let target = active as? MediaLaunchable else {
actionMessage = "\(active?.name ?? "This host") cant play library items"
return
}
activeSeries = series
activeCategory = category
actionMessage = nil
let hostName = active?.name ?? "?"
Log.info("launch \(request) on \(hostName)")
Task {
let ok = await target.launch(request)
await refreshActive()
guard ok else {
let reason = activeSnapshot.state == .unreachable
? "host unreachable" : "file may be missing or its mount is offline"
actionMessage = "Couldnt play on \(active?.name ?? "")\(reason)"
Log.error("launch failed on \(hostName)\(reason): \(request)")
return
}
Log.info("launch ok on \(hostName)")
// Seek to a saved position after the file loads works for VLC files and
// for episodes launched on black (generic mpv absolute seek). Everything
// launches by path now, so the app always owns the seek.
if let resumeSeconds, resumeSeconds > 1 {
try? await Task.sleep(for: .seconds(1.5))
await active?.seek(toSeconds: Int(resumeSeconds))
}
await applyTrackPreferenceToActive()
await refreshTracks()
}
}
// MARK: Tracks (sub/dub)
public var activeSupportsTracks: Bool { active is TrackSelectable }
/// Effective sub/dub choice for what's playing (per-series override anime
/// default global default).
public var currentDubSub: DubSub { trackPrefs.choice(series: activeSeries, category: activeCategory) }
public var globalDubSub: DubSub { trackPrefs.globalDefault }
/// Re-read the active target's track list into the published audio/subtitle
/// arrays (drives the Player menus). Safe to call on target change.
public func refreshTracks() async {
guard let t = active as? TrackSelectable else {
if !audioTracks.isEmpty { audioTracks = [] }
if !subtitleTracks.isEmpty { subtitleTracks = [] }
return
}
let all = await t.tracks()
audioTracks = all.filter { $0.kind == .audio }
subtitleTracks = all.filter { $0.kind == .subtitle }
}
private func applyTrackPreferenceToActive() async {
guard let t = active as? TrackSelectable else { return }
let c = trackPrefs.choice(series: activeSeries, category: activeCategory)
await t.applyLanguagePreference(audioLangs: c.audioLangs, subLangs: c.subLangs, subsEnabled: c.subsEnabled)
}
/// Set the sub/dub choice: scoped to the active series when one is known,
/// otherwise the global default. Persisted, then applied + re-read.
public func setDubSub(_ choice: DubSub) {
if let s = activeSeries { trackPrefs.setSeries(s, choice) }
else { trackPrefs.globalDefault = choice }
TrackPreferenceStore.save(trackPrefs)
Task { await applyTrackPreferenceToActive(); await refreshTracks() }
}
public func selectAudioTrack(_ id: Int) {
guard let t = active as? TrackSelectable else { return }
Task { await t.setAudioTrack(id); await refreshTracks() }
}
public func selectSubtitleTrack(_ id: Int?) {
guard let t = active as? TrackSelectable else { return }
Task { await t.setSubtitleTrack(id); await refreshTracks() }
}
// 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 }
Log.info("switchRelease \(id) on \(active?.name ?? "?")")
Task {
await q.switchRelease(id)
lastReleaseKey = nil // force a refetch (current flag moved)
await refreshActive()
await refreshReleases()
}
}
// MARK: Sleep timer
/// A scheduled auto-stop. `.at` fires at a wall-clock instant (the N-minute
/// presets); `.endOfEpisode` fires when the current item finishes. On fire we
/// *stop* the active target (drop its signal) rather than pause, so the TV
/// powers itself off on its own no-signal schedule.
public enum SleepTimer: Equatable, Sendable {
case off
case at(Date)
case endOfEpisode
}
public private(set) var sleep: SleepTimer = .off
/// When set, firing the sleep timer also puts *this* Mac to sleep (after
/// stopping the TV). Opt-in and intentionally not persisted it resets to off
/// each launch so the machine never sleeps unexpectedly from a stale setting.
public var sleepSystem = false
private var sleepTask: Task<Void, Never>?
/// Baseline captured when `.endOfEpisode` was armed: the media title and the
/// playlist position of the item that was playing. We fire when playback moves
/// off that item (playlist advanced, title changed) or the item reaches its end.
/// Both may be nil if arming raced the first poll `checkEndOfEpisode` then
/// captures the baseline lazily on the next good poll (the old code compared
/// against nil forever, so the timer silently never fired).
private var sleepArmTitle: String?
private var sleepArmPos: Int?
public var sleepArmed: Bool { sleep != .off }
/// When the timed sleep fires (nil for off / end-of-episode) for the countdown.
public var sleepFiresAt: Date? { if case let .at(d) = sleep { return d }; return nil }
/// Arm a timed sleep `minutes` from now. Replaces any existing timer.
public func setSleepTimer(minutes: Int) {
cancelSleep(log: false)
guard minutes > 0 else { return }
let fire = Date().addingTimeInterval(TimeInterval(minutes * 60))
sleep = .at(fire)
Log.info("sleep timer armed: \(minutes) min")
sleepTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(Double(minutes * 60)))
guard !Task.isCancelled else { return }
await self?.fireSleep()
}
}
/// Arm a sleep that fires when the currently-playing item ends.
public func setSleepAtEpisodeEnd() {
cancelSleep(log: false)
let st = activeSnapshot.status
sleepArmTitle = st.title
sleepArmPos = st.playlistPos
sleep = .endOfEpisode
Log.info("sleep timer armed: end of episode (\(sleepArmTitle ?? "current"), pos \(sleepArmPos.map(String.init) ?? "?"))")
}
public func cancelSleep() { cancelSleep(log: true) }
private func cancelSleep(log: Bool) {
sleepTask?.cancel(); sleepTask = nil
sleepArmTitle = nil; sleepArmPos = nil
if log, sleep != .off { Log.info("sleep timer cancelled") }
sleep = .off
}
private func fireSleep() async {
let was = sleep
let alsoSystem = sleepSystem
sleepTask = nil; sleepArmTitle = nil; sleep = .off
guard was != .off else { return }
// Stop (not pause): drops the host's video signal so the TV's own
// no-signal timer powers it off the way it's scheduled to.
await active?.stop()
await refreshActive()
actionMessage = alsoSystem ? "Sleep timer — stopped playback, sleeping this Mac…"
: "Sleep timer — playback stopped"
Log.info("sleep timer fired — stopped \(active?.name ?? "?")\(alsoSystem ? " + system sleep" : "")")
if alsoSystem { await Self.putSystemToSleep() }
}
/// Sleep this Mac immediately. `pmset sleepnow` is the immediate-sleep verb
/// it needs no elevated privileges and (unlike telling System Events to sleep
/// via Apple Events) triggers no TCC Automation prompt. Spawned off the main
/// actor per `ProcessRunner`'s contract.
private nonisolated static func putSystemToSleep() async {
await Task.detached(priority: .utility) {
_ = ProcessRunner.run("/usr/bin/pmset", ["sleepnow"])
}.value
}
/// Called each tick while `.endOfEpisode` is armed. Fires when playback leaves
/// the armed item. Captures the baseline lazily if arming raced the first poll.
private func checkEndOfEpisode() {
guard case .endOfEpisode = sleep else { return }
let s = activeSnapshot.status
// Arming raced the first good poll no baseline yet. Capture it now (from
// a valid status) and wait for the NEXT poll to compare against it.
if sleepArmTitle == nil && sleepArmPos == nil {
if s.title != nil || s.playlistPos != nil { sleepArmTitle = s.title; sleepArmPos = s.playlistPos }
return
}
if Self.shouldFireEndOfEpisode(armedTitle: sleepArmTitle, armedPos: sleepArmPos, status: s) {
Task { await fireSleep() }
}
}
/// Pure decision: has playback left the armed item? True when the playlist
/// advanced past the armed position, the media title changed (hosts without a
/// playlist position, e.g. VLC), or the current item reached its end (covers
/// the LAST item, where nothing advances the old `!playing` check was dead
/// for mpv, whose poll always reports playing while reachable). Deliberately
/// does NOT fire on a transient unreachable poll.
nonisolated static func shouldFireEndOfEpisode(armedTitle: String?, armedPos: Int?,
status s: PlaybackStatus) -> Bool {
if let a = armedPos, let now = s.playlistPos, now > a { return true }
if let a = armedTitle, let now = s.title, now != a { return true }
if let pos = s.position, let dur = s.duration, dur > 0, pos >= dur - 2 { return true }
return false
}
/// Keep-last-known: on unreachable we flip the badge but never wipe the
/// previously-known playback state. Connectivity transitions are logged (once
/// per change, not every poll).
private func apply(_ r: PollResult, to id: String) {
var snap = snapshots[id] ?? Snapshot()
let was = snap.state
if r.reachable {
snap.state = .connected
if let s = r.status {
snap.status = s
statusCache[id] = PlayerStatusCache.Entry(status: s, capturedAt: Date())
persistCacheThrottled()
}
} else {
snap.state = .unreachable
}
if was != snap.state, was != .checking {
let name = targets.first { $0.id == id }?.name ?? id
if snap.state == .unreachable { Log.error("host \(name) became unreachable") }
else { Log.info("host \(name) reconnected") }
}
snapshots[id] = snap
}
/// Persist the status cache at most every few seconds (position ticks every
/// poll; we don't need to write that often), off the main thread.
private func persistCacheThrottled() {
let now = Date()
guard now.timeIntervalSince(lastCacheWrite) > 4 else { return }
lastCacheWrite = now
let snapshot = statusCache
Task.detached { PlayerStatusCache.save(snapshot) }
}
}