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? private var statsTask: Task? private var devicesStatsTask: Task? /// 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. Title↔path 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 } /// 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") let fetched = await OfflineCacheController.fetchFile(path: path, show: show) { [self] msg in if !Self.shouldDeferToOfflineCacheUI(msg) { 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")’ can’t 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)’" : "Couldn’t 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")’ can’t 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 isn’t running" actionMessage = "Couldn’t 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? /// 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) } } }