tv-anarchy/Sources/TVAnarchyCore/PlayerController.swift

1429 lines
66 KiB
Swift
Raw Permalink Normal View History

import Foundation
import Observation
#if canImport(AppKit)
import AppKit
#endif
/// 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] = []
/// Per-device load, sampled while the Devices tab is visible. Only HostStatsProvider
/// targets report (e.g. black); others stay absent. Drives the load badge there.
public private(set) var hostStatsByID: [String: HostStats] = [:]
/// Per-device expected helper hash (sha256 of the repo's vendored script for
/// the device's delegated-command bin), computed at reload. Devices without a
/// known helper or an app run without a repo checkout are simply absent.
private var expectedHelperSHA: [String: String] = [:]
/// 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()
/// Fired when the app deliberately starts an item (launch / queue fire) so
/// resume position can be saved. Playlist auto-advance does NOT fire this
/// only a finished episode (see `onEpisodeFinished`) advances Continue Watching.
public var onItemStarted: ((_ path: String, _ resumeSeconds: Double?) -> Void)?
/// Fired once per episode when playback passes the finished threshold (~92%).
/// RootView wires this to `LibraryController.recordPlay(finished:)` so the rail
/// advances only on episodes actually watched through, not titles skipped past.
public var onEpisodeFinished: ((_ path: String, _ durationSeconds: Double?) -> Void)?
/// Live progress while an item plays (throttled inside tick). Wires to
/// LibraryController.recordPosition so resume targets and Netflix episode bars
/// update continuously for the currently-watched episode without waiting for
/// finish or relaunch. Path + pos + optional dur from the current status.
public var onProgressUpdate: ((_ path: String, _ positionSeconds: Double, _ durationSeconds: Double?) -> Void)?
/// The paths of the most recently fired play (queue or single launch), for
/// mapping a polled title back to a path; the last one already reported.
/// Paths of the queue the app fired (multi-episode enqueue or single launch).
public private(set) var playbackQueuePaths: [String] = []
private var lastReportedPath: String?
/// Episode we've seen pinned at its end only fire `next()` after it stays
/// there long enough that the host clearly isn't advancing on its own.
private var autoAdvanceStuckPath: String?
private var autoAdvanceStuckSince: Date?
/// After we send `next()`, ignore repeats until the host reports a new item.
private var autoAdvanceFiredForPath: String?
/// VLC zeros time/length and stops when a playlist item ends without advancing.
/// Remember which path was at the end so auto-advance can still fire `next()`.
private var lastNearEndPath: String?
/// Last path we already reported as finished to the watchlog.
private var lastFinishedReportedPath: String?
/// Throttle for live onProgressUpdate (while playing we poll fast; we only
/// want to append resume lines to the watchlog every ~12s or on big jumps).
private var lastProgressReport = Date.distantPast
private let progressReportInterval: TimeInterval = 12
private var pollTask: Task<Void, Never>?
private var statsTask: Task<Void, Never>?
private var devicesStatsTask: Task<Void, Never>?
/// Forwards the Mac's system transport and volume keys to the active target
/// and publishes Now Playing. Gated by `forwardMediaKeys` / `forwardVolumeKeys`.
private let nowPlaying = NowPlayingController()
/// Unit-test observation of which monitors `applyMediaKeyForwarding` registered.
internal var mediaKeyForwardingState: (transport: Bool, volume: Bool) {
(nowPlaying.isEnabled, nowPlaying.volumeKeysEnabled)
}
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
/// Settings owner wired from RootView for display preference + routing.
private weak var library: LibraryProviding?
private weak var playlist: PlaylistController?
/// Connected displays, refreshed on launch and when screens change.
public private(set) var displays: [DisplayInfo] = []
#if canImport(AppKit)
private var displayObserver: NSObjectProtocol?
#endif
/// True while auto-launching local VLC's HTTP interface.
public private(set) var vlcEnsuring = false
private var vlcEnsureInFlight = false
private var lastVlcEnsure = Date.distantPast
public init() {
statusCache = PlayerStatusCache.load()
reload()
refreshDisplays()
}
// --- Boundary to Media Management piece (Library + Download pillars) per v2 plan ---
// Playback (this class + viewer clients) consumes Library data, watch state (SSOT), and
// delegates cache prep (OfflineCacheController). Management owns acquisition/indexing/policy.
// No playback logic here; glue is narrow (attach, recordPlay via callbacks, ensure*Copies).
// We depend on LibraryProviding protocol (DIP) not concrete LibraryController.
// See v2/plan.md "Media management vs. viewer client playback as two pieces".
public func attach(library: LibraryProviding) { self.library = library }
public func attach(playlist: PlaylistController) { self.playlist = playlist }
public var hasExternalTV: Bool { displays.contains { !$0.isBuiltIn } }
public var effectivePlaybackDisplay: DisplayInfo? {
DisplayInfo.resolve(preference: library?.playbackDisplayId, from: displays)
}
public func refreshDisplays() { displays = DisplayService.list() }
/// Persist a display choice (nil = auto) and route the active local player.
public func setPlaybackDisplay(_ id: UInt32?) {
library?.playbackDisplayId = id
Task { await applyPlaybackDisplay() }
}
public func startDisplayMonitoring() {
#if canImport(AppKit)
displayObserver.map { NotificationCenter.default.removeObserver($0) }
refreshDisplays()
displayObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.onDisplaysChanged() }
}
#endif
}
private func onDisplaysChanged() {
let hadTV = hasExternalTV
refreshDisplays()
guard active?.kind.isLocal == true else { return }
if library?.playbackDisplayId == nil, hasExternalTV, !hadTV {
Task { await applyPlaybackDisplay() }
}
}
/// Route the active local player (VLC / QuickTime) to the resolved display.
public func applyPlaybackDisplay() async {
guard let host = active else { return }
await applyPlaybackDisplay(for: host)
}
private func applyPlaybackDisplay(for host: any PlayerTarget) async {
guard host.kind.isLocal, let display = effectivePlaybackDisplay else { return }
try? await Task.sleep(for: .seconds(0.8))
switch host.kind {
case .vlc: await DisplayService.routeVlc(to: display)
case .quicktime: await DisplayService.routeQuickTime(to: display)
default: break
}
}
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. Restores the user's last-selected
/// host from settings when still configured; otherwise falls back to the TV.
public func reload() {
let cfg = DevicesConfig.loadOrSeed()
targets = cfg.devices.compactMap(Self.makeTarget)
expectedHelperSHA = cfg.devices.reduce(into: [:]) { acc, d in
if let bin = d.commands?.helperBin,
let sha = HelperDeployment.expectedSHA(forBin: bin) { acc[d.id] = sha }
}
let saved = library?.activePlayerId ?? SettingsStore.load().activePlayerId
if let saved, targets.contains(where: { $0.id == saved }) {
activeID = saved
} else 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.resolvedVlcConn() 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: h.resolvedMpvConn() ?? MpvConn(endpoints: s.endpoints),
commands: CommandsConfig.blackTVDefaults(bin: s.bin))
case .mpvIPC:
guard let m = h.resolvedMpvConn() else { return nil }
return MpvTarget(id: h.id, name: h.name, mpv: m, commands: h.commands)
case .roku:
guard let r = h.resolvedRokuConn() else { return nil }
return RokuTarget(id: h.id, name: h.name, host: r.host, port: r.port)
case .quicktime:
return QuickTimeTarget(id: h.id, name: h.name)
case .registry:
return nil // registry-only entry nothing to connect to
}
}
// 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)
if host.kind.isLocal {
OfflinePolicyActuator.shared.scheduleApply(host.resolvedOfflinePolicy())
}
}
public func updateOfflinePolicy(deviceId: String, _ policy: OfflineCachePolicy) {
var hosts = DevicesConfig.loadOrSeed().devices
guard let i = hosts.firstIndex(where: { $0.id == deviceId }) else { return }
hosts[i].offlinePolicy = policy
saveDevices(hosts)
if hosts[i].kind.isLocal {
OfflinePolicyActuator.shared.scheduleApply(policy)
}
}
public func updateStreamPolicy(deviceId: String, _ policy: StreamPolicy) {
var hosts = DevicesConfig.loadOrSeed().devices
guard let i = hosts.firstIndex(where: { $0.id == deviceId }) else { return }
hosts[i].streamPolicy = policy
saveDevices(hosts)
}
public func deleteDevice(_ id: String) {
saveDevices(DevicesConfig.loadOrSeed().devices.filter { $0.id != id })
}
/// Re-seed the default local player set (optional storage node when env provides it).
public func resetDevicesToDefault() { saveDevices(DevicesConfig.seeded().devices) }
// MARK: Helper deployment freshness + update (Devices tab)
/// Is `id`'s deployed helper the one vendored in the repo? nil when freshness
/// can't be judged (no known helper, no repo checkout, or no stats report yet
/// an unreachable device shouldn't double-flag as outdated).
public func helperFreshness(_ id: String) -> HelperDeployment.Freshness? {
guard let stats = hostStatsByID[id] else { return nil }
return HelperDeployment.freshness(expected: expectedHelperSHA[id],
reported: stats.helper_sha)
}
/// The repo-side hash `id`'s helper is held against (the device summary
/// shows deployed vs expected side by side).
public func helperExpectedSHA(_ id: String) -> String? { expectedHelperSHA[id] }
/// Whether the app can push the vendored helper to `id` (needs a repo checkout).
public func canUpdateService(_ id: String) -> Bool {
(targets.first { $0.id == id } as? ServiceUpdatable)?.canUpdateService ?? false
}
/// The full self-heal: push the repo's helper onto the device, then restart
/// the player service through the fresh script, then re-poll status AND
/// stats so the row's freshness badge reflects the new deploy immediately.
@discardableResult
public func updateService(_ id: String) async -> Bool {
guard let target = targets.first(where: { $0.id == id }),
let updatable = target as? ServiceUpdatable,
updatable.canUpdateService else { return false }
let updated = await updatable.updateService()
Log.info("service update on \(target.name): \(updated ? "ok" : "FAILED")")
guard updated else { return false }
let restarted = await (target as? ServiceRestartable)?.restartService() ?? true
await refreshSnapshot(for: target)
if let p = target as? HostStatsProvider, let s = await p.stats() {
hostStatsByID[id] = s
}
return restarted
}
// MARK: Device service restart (Devices tab)
/// Whether `id`'s host-side player service can be restarted (the target
/// supports it AND has the delegated restart command configured).
public func canRestartService(_ id: String) -> Bool {
(targets.first { $0.id == id } as? ServiceRestartable)?.canRestartService ?? false
}
/// Restart `id`'s host-side player service, then re-poll the device so its
/// row reflects the outcome. Returns whether the restart command succeeded.
@discardableResult
public func restartService(_ id: String) async -> Bool {
guard let target = targets.first(where: { $0.id == id }),
let restartable = target as? ServiceRestartable,
restartable.canRestartService else { return false }
let ok = await restartable.restartService()
Log.info("service restart on \(target.name): \(ok ? "ok" : "FAILED")")
await refreshSnapshot(for: target)
return ok
}
/// 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() } } }
/// True while the Devices tab is on screen gates per-device load sampling there.
public var devicesVisible = false { didSet { if devicesVisible != oldValue { startDevicesStats() } } }
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))
}
}
}
/// Sample every HostStatsProvider target's load while the Devices tab is up, on a
/// relaxed cadence (this is a config screen, not the live transport view).
private func startDevicesStats() {
devicesStatsTask?.cancel()
guard devicesVisible else { hostStatsByID = [:]; return }
devicesStatsTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshDeviceStats()
try? await Task.sleep(for: .seconds(4))
}
}
}
private func refreshDeviceStats() async {
var next: [String: HostStats] = [:]
for t in targets {
guard let p = t as? HostStatsProvider, let s = await p.stats() else { continue }
next[t.id] = s
}
hostStatsByID = next
}
public func stop() { pollTask?.cancel(); statsTask?.cancel(); devicesStatsTask?.cancel(); nowPlaying.disable() }
/// Enable/disable transport and volume media-key forwarding per settings. Called
/// on every `start()` (idempotent), so flipping either setting takes effect live.
/// Handlers route to the active target via the existing `command` path.
public func applyMediaKeyForwarding() {
let s = SettingsStore.load()
let h = NowPlayingController.Handlers(
toggle: { [weak self] in self?.togglePlayPause() },
play: { [weak self] in self?.play() },
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(); await $0.resume() } },
previous: { [weak self] in self?.command { await $0.previous() } },
seek: { [weak self] secs in self?.command { await $0.seek(toSeconds: Int(secs)) } },
volumeUp: { [weak self] in self?.mediaKeyVolume(delta: 1) ?? false },
volumeDown: { [weak self] in self?.mediaKeyVolume(delta: -1) ?? false })
if s.forwardMediaKeys { nowPlaying.enableTransport(h) } else { nowPlaying.disableTransport() }
if s.forwardVolumeKeys { nowPlaying.enableVolumeKeys(h) } else { nowPlaying.disableVolumeKeys() }
}
/// Media-key volume nudge only while actively playing; routes to the active
/// target instead of the Mac's system volume. Returns whether the key was consumed.
@discardableResult
private func mediaKeyVolume(delta direction: Int) -> Bool {
guard NowPlayingController.isActivelyPlaying(activeSnapshot.status),
let host = active else { return false }
let step = NowPlayingController.volumeStep(scale: host.volumeScale) * direction
let next = NowPlayingController.adjustedVolume(
current: activeSnapshot.status.volume, delta: step, scale: host.volumeScale)
command { await $0.setVolume(next) }
return true
}
// MARK: - High-level play / resume (for system media keys + idle "play" button)
/// High-level "Play" action.
/// - If a track/playlist is already loaded on the host (title known or we fired one recently),
/// just ensure it is unpaused / playing.
/// - Otherwise (nothing playing), resume the most recent Continue Watching item from its
/// last saved position in the track. For series this also queues the rest of the show
/// (matching the behavior of tapping a Continue card on Home/Library/Player).
public func play() {
let s = activeSnapshot.status
// If the host currently reports a loaded item (title visible) or is playing,
// just ensure playback (unpause / resume current). Once the host clears the
// title (end of list, stop, etc.) we treat it as idle even if we remember an
// old fired queue this lets "press play after last ep finished successfully"
// advance to the *next* via the updated Continue Watching rail instead of
// re-starting the just-finished episode.
if s.title != nil || s.playing {
command { await $0.resume() }
return
}
guard let lib = library,
let pl = playlist,
let item = lib.continueWatching.first else {
note("No recent playback to resume")
return
}
if pl.playContinue(item, shows: lib.shows, on: self) { return }
guard let kind = activeKind,
let req = lib.launchRequest(continue: item, targetKind: kind) else {
note("No player selected")
return
}
let startRemote = MediaPaths.toRemote(item.path)
let live = lib.resumePositions()[startRemote] ?? 0
let r = max(item.positionSeconds ?? 0, live)
launch(req, series: item.show, resumeSeconds: r)
}
/// Toggle play/pause at the high level. When the host has nothing loaded (title nil),
/// treat as "resume last / next logical" so media keys work after a series finishes.
public func togglePlayPause() {
if activeSnapshot.status.title == nil {
play()
return
}
command { await $0.playPause() }
}
/// 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)
}
}
}
private func vlcHttpConfig(for id: String) -> VLCLauncher.HttpConfig? {
guard let d = DevicesConfig.loadOrSeed().devices.first(where: { $0.id == id }),
d.kind == .vlc, let v = d.vlc else { return nil }
return VLCLauncher.httpConfig(host: v.host, port: v.port)
}
/// Kick off VLC launch in the background must not block `tick()` (UI freeze).
private func scheduleVlcEnsureIfNeeded(for id: String? = nil) {
let targetId = id ?? activeID
guard let t = targets.first(where: { $0.id == targetId }), t.kind == .vlc else { return }
guard snapshot(targetId).state != .connected else { return }
guard !vlcEnsureInFlight, Date().timeIntervalSince(lastVlcEnsure) > 20 else { return }
vlcEnsureInFlight = true
vlcEnsuring = true
lastVlcEnsure = Date()
Task {
let ok: Bool
if let cfg = vlcHttpConfig(for: targetId) {
ok = await VLCLauncher.ensureRunning(cfg)
if !ok { Log.error("VLC HTTP still unreachable for \(t.name)") }
} else { ok = false }
vlcEnsureInFlight = false
vlcEnsuring = false
if ok { await refreshSnapshot(for: t) }
}
}
/// Start local VLC before play awaited, but launch work is off the main thread.
@discardableResult
private func ensureVlcReady(id: String) async -> Bool {
guard let cfg = vlcHttpConfig(for: id) else { return false }
vlcEnsuring = true
defer { vlcEnsuring = false }
return await VLCLauncher.ensureRunning(cfg)
}
/// 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
scheduleVlcEnsureIfNeeded()
for t in targets where t.id == activeID || tickCount % 4 == 0 {
apply(await t.poll(), to: t.id)
}
detectAdvance()
updateNearEndTracking()
checkEpisodeFinished()
checkAutoAdvance()
checkEndOfEpisode()
reportLiveProgressIfNeeded()
pushNowPlaying()
}
/// Remember what just fired and report it as started (watch history).
private func noteItemStarted(_ path: String, resume: Double?, fired: [String]) {
playbackQueuePaths = fired
lastReportedPath = path
onItemStarted?(path, resume)
}
/// Path of the file currently reported by the active player, if it maps to
/// the fired queue. VLC clears the title when it stops at end-of-item, so fall
/// back to the last path the app fired while it's still in the queue.
public var currentPlaybackPath: String? {
resolvedQueuePath(status: activeSnapshot.status)
}
private func resolvedQueuePath(status s: PlaybackStatus) -> String? {
if let title = s.title, let m = Self.matchPath(title: title, in: playbackQueuePaths) { return m }
if let last = lastReportedPath, playbackQueuePaths.contains(last) { return last }
return playbackQueuePaths.first
}
private func upNextPath(after current: String) -> String? {
guard let i = playbackQueuePaths.firstIndex(of: current), i + 1 < playbackQueuePaths.count else { return nil }
return playbackQueuePaths[i + 1]
}
/// Paths from `paths` that already have a local offline copy (when offline mode
/// requires one). Empty input empty; nothing cached unchanged (enqueue fails).
private func offlineAvailable(_ paths: [String], adult: Bool) -> [String] {
guard active?.kind.isLocal == true, requiresLocalCopy(adult: adult) else { return paths }
let available = paths.filter { MediaPaths.localCopy(of: $0) != nil }
return available.isEmpty ? paths : available
}
/// Remember when the current item reached the end VLC stops with zero clocks.
private func updateNearEndTracking() {
let s = activeSnapshot.status
guard activeSnapshot.state == .connected else { return }
if let path = resolvedQueuePath(status: s),
(Self.isAtEpisodeEnd(status: s) || Self.isEpisodeFinished(status: s)) {
lastNearEndPath = path
return
}
if let title = s.title,
let matched = Self.matchPath(title: title, in: playbackQueuePaths),
matched != lastNearEndPath {
lastNearEndPath = nil
return
}
if s.playing, let pos = s.position, let dur = s.duration, dur > 0, pos < dur - 30 {
lastNearEndPath = nil
}
}
/// True when the host is pinned at the end and won't advance on its own.
private func isStuckAtEpisodeEnd(status s: PlaybackStatus) -> Bool {
if Self.isAtEpisodeEnd(status: s) { return true }
guard !s.playing, let stuck = lastNearEndPath else { return false }
return resolvedQueuePath(status: s) == stuck
}
/// Index in `playbackQueuePaths` of the current item; nil when unknown.
public var currentQueueIndex: Int? {
guard let path = currentPlaybackPath else { return nil }
return playbackQueuePaths.firstIndex(of: path)
}
/// Next queued path after the current item, if any.
public var upNextPath: String? {
guard let i = currentQueueIndex, i + 1 < playbackQueuePaths.count else { return nil }
return playbackQueuePaths[i + 1]
}
/// Human label for a library path (episode label when known).
public static func label(for path: String, library: LibraryProviding) -> String {
let remote = MediaPaths.toRemote(path)
for show in library.shows {
if let ep = show.episodes.first(where: { MediaPaths.toRemote($0.path) == remote }) {
return ep.label
}
}
return (path as NSString).lastPathComponent
}
/// Library episode matching a player-reported title (filename or path).
public static func episode(forTitle title: String,
library: LibraryProviding,
preferShow: String? = nil) -> (show: CachedShow, episode: CachedEpisode)? {
episode(forTitle: title, in: library.shows, preferShow: preferShow)
}
/// Library episode matching a player-reported title across a show list.
public static func episode(forTitle title: String,
in shows: [CachedShow],
preferShow: String? = nil) -> (show: CachedShow, episode: CachedEpisode)? {
let t = (title as NSString).lastPathComponent.lowercased()
let tNoExt = (t as NSString).deletingPathExtension
guard !tNoExt.isEmpty else { return nil }
let ordered = preferShow.map { name in
shows.filter { $0.name == name } + shows.filter { $0.name != name }
} ?? shows
for show in ordered {
for ep in show.episodes {
let f = (ep.path.lowercased() as NSString).lastPathComponent
if f == t || (f as NSString).deletingPathExtension == tNoExt {
return (show, ep)
}
}
}
if let (season, episode) = LibraryScanner.parseSxxEyy(tNoExt) {
for show in ordered {
if let ep = show.episodes.first(where: { $0.season == season && $0.episode == episode }) {
return (show, ep)
}
}
}
return nil
}
/// Compact season/episode code, e.g. S01E03.
public static func episodeCode(_ ep: CachedEpisode) -> String {
String(format: "S%02dE%02d", ep.season, ep.episode)
}
/// Episode title without show name or SxxExx prefix for hero display.
public static func shortEpisodeTitle(_ label: String) -> String {
if let range = label.range(of: #"S\d{1,2}E\d{1,2}"#, options: .regularExpression) {
let after = label[range.upperBound...].trimmingCharacters(in: .whitespaces)
if !after.isEmpty { return String(after) }
}
return label
}
/// Clean title for the player hero library label when known, else filename sans extension.
public static func displayTitle(for status: PlaybackStatus,
library: LibraryProviding,
preferShow: String? = nil) -> String {
displayTitle(forTitle: status.title, in: library.shows, preferShow: preferShow)
}
/// Clean title from a raw player title and show list.
public static func displayTitle(forTitle raw: String?,
in shows: [CachedShow],
preferShow: String? = nil) -> String {
guard let raw else { return "Playing" }
if let (_, ep) = episode(forTitle: raw, in: shows, preferShow: preferShow) {
if let title = ep.episodeTitle?.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty { return title }
let short = shortEpisodeTitle(ep.displayName)
if !short.isEmpty { return short }
return episodeCode(ep)
}
let base = (raw as NSString).lastPathComponent
return (base as NSString).deletingPathExtension
}
/// Code + short title for queue chips (S01E03 / episode name).
public static func queueChipParts(for path: String,
library: LibraryProviding) -> (code: String, title: String) {
queueChipParts(for: path, in: library.shows)
}
/// Code + short title for queue chips across a show list.
public static func queueChipParts(for path: String,
in shows: [CachedShow]) -> (code: String, title: String) {
let remote = MediaPaths.toRemote(path)
for show in shows {
if let ep = show.episodes.first(where: { MediaPaths.toRemote($0.path) == remote }) {
let hero = ep.episodeTitle ?? shortEpisodeTitle(ep.displayName)
return (episodeCode(ep), hero)
}
}
let name = (path as NSString).lastPathComponent
return ("", (name as NSString).deletingPathExtension)
}
/// Download toasts are redundant while the offline-cache panel is visible.
public static func shouldDeferToOfflineCacheUI(_ message: String) -> Bool {
OfflineCacheController.isCacheActive
&& (message.contains("Downloading") || message.contains("not downloaded"))
}
/// Position line under the hero title queue, library ordinal, or host playlist.
public func positionLine(library: LibraryProviding) -> String? {
let s = activeSnapshot.status
if playbackQueuePaths.count > 1, let idx = currentQueueIndex {
return "Queue \(idx + 1) of \(playbackQueuePaths.count)"
}
if let title = s.title,
let (show, ep) = Self.episode(forTitle: title, library: library, preferShow: activeSeries) {
let code = Self.episodeCode(ep)
if let ord = show.orderedEpisodes.firstIndex(where: { $0.path == ep.path }),
let total = show.knownEpisodeCount {
return "\(code) · \(ord + 1) of \(total)"
}
return code
}
if let n = s.playlistCount, let i = s.playlistPos, n > 1 {
return "Host playlist \(i + 1) of \(n)"
}
return nil
}
/// True when actively playing and still inside the configured intro window.
public var shouldOfferSkipIntro: Bool {
let skip = SettingsStore.load().skipIntroSeconds
let s = activeSnapshot.status
guard skip > 0, s.title != nil,
NowPlayingController.isActivelyPlaying(s),
let pos = s.position else { return false }
return pos < Double(skip) - 2
}
/// Jump past the configured intro offset.
public func skipIntro() {
let sec = SettingsStore.load().skipIntroSeconds
guard sec > 0 else { return }
Task { await commandAwait { await $0.seek(toSeconds: sec) } }
}
/// Seconds until the current episode ends; nil when unknown.
public var secondsRemaining: Double? {
guard let pos = activeSnapshot.status.position,
let dur = activeSnapshot.status.duration, dur > 0 else { return nil }
return max(0, dur - pos)
}
/// Auto-advance through the fired queue: when the active target's reported
/// title maps to a DIFFERENT fired path than last reported, that item
/// started. Titlepath matching is by exact filename (with or without
/// extension), so an unrelated media title can't produce a false event.
private func detectAdvance() {
guard playbackQueuePaths.count > 1,
let title = activeSnapshot.status.title,
let path = Self.matchPath(title: title, in: playbackQueuePaths),
path != lastReportedPath else { return }
lastReportedPath = path
lastFinishedReportedPath = nil
autoAdvanceStuckPath = nil
autoAdvanceStuckSince = nil
autoAdvanceFiredForPath = nil
}
/// Record a finish once the current item crosses the watched-through threshold.
private func checkEpisodeFinished() {
guard onEpisodeFinished != nil else { return }
let s = activeSnapshot.status
guard activeSnapshot.state == .connected,
let path = resolvedQueuePath(status: s) ?? playbackQueuePaths.first,
path != lastFinishedReportedPath,
isEpisodeFinishedForWatchlog(status: s, path: path) else { return }
lastFinishedReportedPath = path
onEpisodeFinished?(path, s.duration)
}
/// Finished threshold, including VLC stopping with zeroed clocks at end-of-item.
private func isEpisodeFinishedForWatchlog(status s: PlaybackStatus, path: String) -> Bool {
if Self.isEpisodeFinished(status: s) { return true }
return !s.playing && lastNearEndPath == path
}
/// Push current pos/dur for the active tracked path so watch history (and thus
/// resume menus, episode progress bars, and continue values) stay live.
/// Throttled + only when we have a meaningful position.
private func reportLiveProgressIfNeeded() {
guard let cb = onProgressUpdate else { return }
let s = activeSnapshot.status
guard activeSnapshot.state == .connected,
let path = resolvedQueuePath(status: s) ?? playbackQueuePaths.first,
let pos = s.position, pos > 0 else { return }
let now = Date()
// Always report if we just started this path or pos jumped a lot; else throttle.
let lastForPath = lastReportedPath == path
let bigJump = abs((s.position ?? 0) - (statusCache[activeID]?.status.position ?? 0)) > 30
if lastForPath && !bigJump && now.timeIntervalSince(lastProgressReport) < progressReportInterval {
return
}
lastProgressReport = now
cb(path, pos, s.duration)
}
/// Same threshold the bridge uses: at/above 92% counts as finished.
nonisolated static func isEpisodeFinished(status s: PlaybackStatus) -> Bool {
guard let dur = s.duration, dur > 0, let pos = s.position else { return false }
return pos >= dur * 0.92
}
/// When a queued episode ends, step to the next item but only if the host is
/// *stuck* there. mpv/VLC normally advance their own playlist; calling `next()`
/// on top of that skips an episode (E02 ends host E03, we also `next()` E04).
/// Also handles a single launch by enqueuing the rest of the show once stuck.
private func checkAutoAdvance() {
let s = activeSnapshot.status
guard activeSnapshot.state == .connected else { return }
let current = resolvedQueuePath(status: s)
guard let current else {
autoAdvanceStuckPath = nil; autoAdvanceStuckSince = nil
return
}
if s.playing, let pos = s.position, let dur = s.duration, dur > 0, pos < dur - 10 {
autoAdvanceStuckPath = nil; autoAdvanceStuckSince = nil
autoAdvanceFiredForPath = nil
}
// Host already moved on nothing to do (and never double-`next()`).
if let next = upNextPath(after: current), let title = s.title,
Self.matchPath(title: title, in: [next]) == next {
autoAdvanceStuckPath = nil; autoAdvanceStuckSince = nil
lastNearEndPath = nil
return
}
if let idx = playbackQueuePaths.firstIndex(of: current),
let ppos = s.playlistPos, ppos > idx {
autoAdvanceStuckPath = nil; autoAdvanceStuckSince = nil
lastNearEndPath = nil
return
}
if let title = s.title,
let matched = Self.matchPath(title: title, in: playbackQueuePaths),
matched != current {
autoAdvanceStuckPath = nil; autoAdvanceStuckSince = nil
lastNearEndPath = nil
return
}
guard isStuckAtEpisodeEnd(status: s) else {
autoAdvanceStuckPath = nil; autoAdvanceStuckSince = nil
return
}
guard current != autoAdvanceFiredForPath else { return }
let now = Date()
if autoAdvanceStuckPath != current {
autoAdvanceStuckPath = current
autoAdvanceStuckSince = now
return
}
guard let since = autoAdvanceStuckSince,
now.timeIntervalSince(since) >= Self.autoAdvanceGraceSeconds else { return }
autoAdvanceFiredForPath = current
autoAdvanceStuckPath = nil
autoAdvanceStuckSince = nil
if upNextPath(after: current) != nil {
Log.info("auto-advance: stuck at \((current as NSString).lastPathComponent) — firing next")
command { await $0.next(); await $0.resume() }
return
}
guard playbackQueuePaths.count == 1,
let series = activeSeries,
let lib = library,
let show = lib.shows.first(where: { $0.name == series }),
show.kind == .series,
let idx = show.orderedEpisodes.firstIndex(where: {
MediaPaths.toRemote($0.path) == MediaPaths.toRemote(current)
}),
idx + 1 < show.orderedEpisodes.count else { return }
let adult = LibraryConfig.isAdult(category: show.category)
let rest = offlineAvailable(Array(show.orderedEpisodes[(idx + 1)...].map(\.path)), adult: adult)
guard !rest.isEmpty else { return }
Log.info("auto-advance: queue \(rest.count) after stuck \((current as NSString).lastPathComponent)")
if active is Enqueueable {
enqueuePlaylist(rest, adult: adult)
} else if let next = rest.first {
launch(.file(path: next), series: series, category: show.category, adult: adult)
}
}
/// Seconds an episode must sit at its end before we assume the host won't advance.
static let autoAdvanceGraceSeconds: TimeInterval = 3
/// True when the polled position is at (or past) the reported runtime.
nonisolated static func isAtEpisodeEnd(status s: PlaybackStatus) -> Bool {
guard let dur = s.duration, dur > 0, let pos = s.position else { return false }
return pos >= dur - 2
}
/// The fired path whose filename matches a player-reported title (mpv/VLC
/// report the filename or a full path as the title). nil = no exact match.
static func matchPath(title: String, in paths: [String]) -> String? {
let t = (title.lowercased() as NSString).lastPathComponent
let tNoExt = (t as NSString).deletingPathExtension
guard !tNoExt.isEmpty else { return nil }
return paths.first {
let f = ($0.lowercased() as NSString).lastPathComponent
return f == t || (f as NSString).deletingPathExtension == tNoExt
}
}
/// 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 else { return }
await refreshSnapshot(for: active)
}
/// Poll one target and store its snapshot. Used to refresh whichever host a
/// launch actually landed on, which may differ from `active` when a
/// not-downloaded file is routed to the TV for that one play.
private func refreshSnapshot(for target: any PlayerTarget) async {
guard !polling else { return }
polling = true
defer { polling = false }
apply(await target.poll(), to: target.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 }
/// Best-effort path + live position of what's playing right now, used to
/// snapshot a recovery point before an interrupting play replaces the queue.
/// nil when nothing is playing (no fired path).
public var currentlyPlaying: (path: String, position: Double)? {
guard let path = lastReportedPath ?? playbackQueuePaths.first else { return nil }
return (path, snapshot(activeID).status.position ?? 0)
}
/// 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
}
/// Persisted stream/offline choice mirrors `AppSettings.playbackMode`.
public var playbackMode: PlaybackMode {
library?.playbackMode ?? SettingsStore.load().playbackMode
}
/// Adult-only playback mode independent of `playbackMode`.
public var adultPlaybackMode: PlaybackMode {
library?.adultPlaybackMode ?? SettingsStore.load().adultPlaybackMode
}
/// Persist shows stream/offline sourcing (does not change the selected host).
public func setPlaybackMode(_ mode: PlaybackMode) {
library?.playbackMode = mode
}
/// 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) {
guard targets.contains(where: { $0.id == id }) else { return }
activeID = id
library?.activePlayerId = id
if targets.first(where: { $0.id == id })?.kind == .vlc {
scheduleVlcEnsureIfNeeded(for: 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.
/// Playback goes to whatever host the user picked. A local player never silently
/// hijacks the home TV missing files are downloaded from black first (single
/// launch) or blocked with a clear note (multi-item queue).
private func localMissing(_ paths: [String], host: any PlayerTarget) -> [String] {
guard host.kind.isLocal else { return [] }
return paths.filter { MediaPaths.localCopy(of: $0) == nil }
}
private func sourcingMode(adult: Bool) -> PlaybackMode {
adult ? adultPlaybackMode : playbackMode
}
/// Offline on a local player block until files are cached; Stream fetch on demand.
private func requiresLocalCopy(adult: Bool) -> Bool {
guard active?.kind.isLocal == true else { return false }
return sourcingMode(adult: adult) == .offline
}
private func ensureLocalCopies(_ paths: [String], show: String?) async -> Bool {
for path in paths where MediaPaths.localCopy(of: path) == nil {
Log.info("fetching \((path as NSString).lastPathComponent) for local play")
// Always surface on-demand playback-fetch progress: this is the user's
// own play action, not the background warmup, so it must be visible even
// while a warmup cache is running (the whole reason "nothing happened"
// when streaming an undownloaded episode mid-warmup).
let fetched = await OfflineCacheController.fetchFile(path: path, show: show) { [self] msg in
actionMessage = msg
}
guard fetched else {
if actionMessage == nil {
actionMessage = "Couldn't download — check VPN/mesh to black, then try again"
}
Log.error("fetch failed for local play: \(path)")
return false
}
}
return true
}
/// Fire a multi-item play queue on the **selected** host. `adult` picks which
/// stream/offline sourcing setting applies on local players.
public func enqueuePlaylist(_ paths: [String], resumeFirst: Double? = nil, adult: Bool = false) {
guard let host = active, let target = host as? Enqueueable else {
actionMessage = "\(active?.name ?? "This host") cant play a queue"; return
}
guard !paths.isEmpty else { actionMessage = "Queue is empty"; return }
if requiresLocalCopy(adult: adult) {
let missing = localMissing(paths, host: host)
if !missing.isEmpty {
actionMessage = "\(missing.count) episode(s) not downloaded — run Offline cache in Settings, or switch to Stream"
return
}
}
let hostName = host.name
let sourcing = sourcingMode(adult: adult)
actionMessage = nil
Log.info("enqueue \(paths.count) on \(hostName) (\(sourcing.label))")
// Management glue (same as launch): ensureLocalCopies for local clients in stream mode.
// Keeps playback execution (queue driving + client control) separate from management.
Task {
if host.kind.isLocal, !requiresLocalCopy(adult: adult) {
guard await ensureLocalCopies(paths, show: nil) else { return }
actionMessage = nil
}
if host.kind == .vlc { _ = await ensureVlcReady(id: host.id) }
let ok = await target.enqueue(paths, replace: true)
if ok, let first = paths.first { noteItemStarted(first, resume: resumeFirst, fired: paths) }
if ok, let r = resumeFirst, r > 1 { await host.seek(toSeconds: Int(r)) }
await refreshSnapshot(for: host)
actionMessage = ok ? "Queued \(paths.count) on \(hostName)"
: "Couldnt queue on \(hostName) — host unreachable"
if ok {
Log.info("enqueue ok on \(hostName)")
await applyPlaybackDisplay(for: host)
await applyTrackPreferenceToActive()
await refreshTracks()
}
else { Log.error("enqueue failed on \(hostName)") }
}
}
public func launch(_ request: LaunchRequest, series: String? = nil,
category: String? = nil, resumeSeconds: Double? = nil,
adult: Bool? = nil) {
guard let host = active, let target = host as? MediaLaunchable else {
actionMessage = "\(active?.name ?? "This host") cant play library items"
return
}
var path: String?
if case let .file(p) = request { path = p }
let isAdult = adult ?? (path.map(LibraryConfig.isAdult(path:)) == true
|| category.map(LibraryConfig.isAdult(category:)) == true)
if requiresLocalCopy(adult: isAdult), let path,
host.kind.isLocal, MediaPaths.localCopy(of: path) == nil {
actionMessage = "Not downloaded — run Offline cache in Settings, or switch to Stream"
return
}
activeSeries = series
activeCategory = category
let hostName = host.name
actionMessage = nil
Log.info("launch \(request) on \(hostName)")
// Management glue: for local viewer clients in .stream mode, fetch on-demand from
// black (management's OfflineCacheController). This is playback-initiated prep,
// not management logic. Respects the two-piece separation (see attach comment above).
Task {
if host.kind == .vlc { _ = await ensureVlcReady(id: host.id) }
if case let .file(path) = request, host.kind.isLocal, MediaPaths.localCopy(of: path) == nil {
guard await ensureLocalCopies([path], show: series) else { return }
actionMessage = nil
}
let ok = await target.launch(request)
await refreshSnapshot(for: host)
guard ok else {
let reason = snapshot(host.id).state == .unreachable
? "host unreachable" : "file may be missing or VLC isnt running"
actionMessage = "Couldnt play on \(hostName)\(reason)"
Log.error("launch failed on \(hostName)\(reason): \(request)")
return
}
Log.info("launch ok on \(hostName)")
if case let .file(path) = request { noteItemStarted(path, resume: resumeSeconds, fired: [path]) }
await applyPlaybackDisplay(for: host)
if let resumeSeconds, resumeSeconds > 1 {
try? await Task.sleep(for: .seconds(1.5))
await host.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) }
}
}