573 lines
26 KiB
Swift
573 lines
26 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] = []
|
||
/// 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")’ can’t 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)’"
|
||
: "Couldn’t 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")’ can’t 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 = "Couldn’t 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) }
|
||
}
|
||
}
|