tv-anarchy/Sources/TVAnarchyCore/PlayerController.swift
Natalie cc5a3a5ce5 fix(playback): show on-demand fetch feedback when streaming an undownloaded episode
Playing an undownloaded episode "did nothing": while the background warmup cache
is active (≈always), shouldDeferToOfflineCacheUI suppressed every
"Downloading…/not downloaded" banner — so the offline "not downloaded" message
and the stream-mode on-demand download both ran invisibly, looking frozen.

- ensureLocalCopies now always sets actionMessage for the on-demand playback
  fetch (it's the user's own play action, not the warmup) instead of deferring.
- LibraryView no longer filters those messages (it has no separate download UI;
  PlayerView keeps its suppression since it has a dedicated dock strip).
- awaitDownload reports live progress via onStatus, so a stream-mode fetch that
  routes through the priority lane (behind the warmup) shows a moving
  "Downloading <ep> to play… N%" banner instead of going silent after "Prioritized".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 03:25:02 -04:00

1428 lines
66 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

import Foundation
import Observation
#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) }
}
}