Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1416 lines
65 KiB
Swift
1416 lines
65 KiB
Swift
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. 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<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) }
|
||
}
|
||
}
|