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>
214 lines
No EOL
8.8 KiB
Swift
214 lines
No EOL
8.8 KiB
Swift
import Foundation
|
||
#if canImport(MediaPlayer)
|
||
import MediaPlayer
|
||
#endif
|
||
#if canImport(AppKit)
|
||
import AppKit
|
||
import IOKit.hidsystem
|
||
#endif
|
||
|
||
/// Bridges the macOS system transport — hardware media keys (F7–F9), Control
|
||
/// Center, the lock screen, AirPods — to whatever TVAnarchy is currently driving,
|
||
/// and publishes Now Playing info so the system shows the right title / scrubber.
|
||
/// Remote commands route to the active target via the supplied `Handlers`; the
|
||
/// info center is refreshed from `PlaybackStatus` on the player's poll tick.
|
||
/// Transport and volume-key forwarding are gated independently by
|
||
/// `AppSettings.forwardMediaKeys` and `AppSettings.forwardVolumeKeys`.
|
||
@MainActor
|
||
public final class NowPlayingController {
|
||
/// Closures that drive the active target. Kept here (not a target ref) so the
|
||
/// controller stays decoupled from PlayerController's internals.
|
||
public struct Handlers {
|
||
public var toggle: () -> Void
|
||
public var play: () -> Void
|
||
public var pause: () -> Void
|
||
public var next: () -> Void
|
||
public var previous: () -> Void
|
||
public var seek: (Double) -> Void // absolute seconds
|
||
/// Return true when the nudge was applied (caller may consume the key event).
|
||
public var volumeUp: () -> Bool
|
||
public var volumeDown: () -> Bool
|
||
public init(toggle: @escaping () -> Void, play: @escaping () -> Void,
|
||
pause: @escaping () -> Void, next: @escaping () -> Void,
|
||
previous: @escaping () -> Void, seek: @escaping (Double) -> Void,
|
||
volumeUp: @escaping () -> Bool, volumeDown: @escaping () -> Bool) {
|
||
self.toggle = toggle; self.play = play; self.pause = pause
|
||
self.next = next; self.previous = previous; self.seek = seek
|
||
self.volumeUp = volumeUp; self.volumeDown = volumeDown
|
||
}
|
||
}
|
||
|
||
/// System transport (play/pause/next/previous/seek + Now Playing) is registered.
|
||
public private(set) var isEnabled = false
|
||
/// F10/F11 volume media keys are monitored locally.
|
||
public private(set) var volumeKeysEnabled = false
|
||
#if canImport(MediaPlayer)
|
||
private var artworkCache: (path: String, art: MPMediaItemArtwork)?
|
||
#endif
|
||
#if canImport(AppKit)
|
||
private var volumeKeyMonitor: Any?
|
||
private var volumeHandlers: Handlers?
|
||
#endif
|
||
|
||
public init() {}
|
||
|
||
// MARK: pure decisions (unit-tested without MediaPlayer)
|
||
|
||
/// Whether playback is actively running (so the system shows the pause glyph
|
||
/// and a moving scrubber). `paused == nil` (hosts that don't report it) counts
|
||
/// as playing when the host says it's playing.
|
||
public static func isActivelyPlaying(_ s: PlaybackStatus) -> Bool {
|
||
s.playing && s.paused != true
|
||
}
|
||
|
||
/// Whether next/previous should be offered — only when there's a real playlist
|
||
/// to step through (multi-item queue, or an enqueue-capable target).
|
||
public static func canStep(playlistCount: Int?, isEnqueueable: Bool) -> Bool {
|
||
isEnqueueable || (playlistCount ?? 0) > 1
|
||
}
|
||
|
||
/// One media-key volume tick — ~25 steps across the target's slider range.
|
||
public static func volumeStep(scale: Int) -> Int { max(1, scale / 25) }
|
||
|
||
/// Next volume after a media-key nudge, clamped to `[0, scale]`.
|
||
public static func adjustedVolume(current: Double?, delta: Int, scale: Int) -> Int {
|
||
let base = Int(current ?? 100)
|
||
return min(scale, max(0, base + delta))
|
||
}
|
||
|
||
// MARK: registration
|
||
|
||
/// Register system transport commands (idempotent). Safe to call repeatedly.
|
||
public func enableTransport(_ h: Handlers) {
|
||
#if canImport(MediaPlayer)
|
||
guard !isEnabled else { return }
|
||
isEnabled = true
|
||
let c = MPRemoteCommandCenter.shared()
|
||
c.togglePlayPauseCommand.addTarget { _ in h.toggle(); return .success }
|
||
c.playCommand.addTarget { _ in h.play(); return .success }
|
||
c.pauseCommand.addTarget { _ in h.pause(); return .success }
|
||
c.nextTrackCommand.addTarget { _ in h.next(); return .success }
|
||
c.previousTrackCommand.addTarget { _ in h.previous(); return .success }
|
||
c.changePlaybackPositionCommand.addTarget { ev in
|
||
guard let e = ev as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||
h.seek(e.positionTime); return .success
|
||
}
|
||
for cmd in commands(c) { cmd.isEnabled = true }
|
||
#endif
|
||
}
|
||
|
||
/// Tear down transport commands and clear Now Playing.
|
||
public func disableTransport() {
|
||
#if canImport(MediaPlayer)
|
||
guard isEnabled else { return }
|
||
isEnabled = false
|
||
let c = MPRemoteCommandCenter.shared()
|
||
for cmd in commands(c) { cmd.removeTarget(nil); cmd.isEnabled = false }
|
||
let center = MPNowPlayingInfoCenter.default()
|
||
center.nowPlayingInfo = nil
|
||
center.playbackState = .stopped
|
||
#endif
|
||
}
|
||
|
||
/// Monitor F10/F11 volume keys (idempotent). Replaces any prior monitor.
|
||
public func enableVolumeKeys(_ h: Handlers) {
|
||
#if canImport(AppKit)
|
||
volumeHandlers = h
|
||
volumeKeysEnabled = true
|
||
startVolumeKeyMonitor()
|
||
#endif
|
||
}
|
||
|
||
/// Stop volume-key monitoring.
|
||
public func disableVolumeKeys() {
|
||
#if canImport(AppKit)
|
||
volumeKeysEnabled = false
|
||
stopVolumeKeyMonitor()
|
||
volumeHandlers = nil
|
||
#endif
|
||
}
|
||
|
||
/// Register transport and volume forwarding together (convenience for tests).
|
||
public func enable(_ h: Handlers) {
|
||
enableTransport(h)
|
||
enableVolumeKeys(h)
|
||
}
|
||
|
||
/// Tear down transport and volume forwarding.
|
||
public func disable() {
|
||
disableVolumeKeys()
|
||
disableTransport()
|
||
}
|
||
|
||
/// Push the current state to the system. No-op when transport is disabled.
|
||
public func update(title: String?, posterPath: String?, position: Double?,
|
||
duration: Double?, playing: Bool, canStep: Bool) {
|
||
#if canImport(MediaPlayer)
|
||
guard isEnabled else { return }
|
||
let center = MPNowPlayingInfoCenter.default()
|
||
var info: [String: Any] = [:]
|
||
info[MPMediaItemPropertyTitle] = title?.isEmpty == false ? title! : "TVAnarchy"
|
||
if let duration, duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = duration }
|
||
if let position { info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = position }
|
||
info[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 1.0 : 0.0
|
||
if let art = artwork(posterPath) { info[MPMediaItemPropertyArtwork] = art }
|
||
center.nowPlayingInfo = info
|
||
center.playbackState = playing ? .playing : .paused
|
||
let c = MPRemoteCommandCenter.shared()
|
||
c.nextTrackCommand.isEnabled = canStep
|
||
c.previousTrackCommand.isEnabled = canStep
|
||
#endif
|
||
}
|
||
|
||
#if canImport(MediaPlayer)
|
||
private func commands(_ c: MPRemoteCommandCenter) -> [MPRemoteCommand] {
|
||
[c.togglePlayPauseCommand, c.playCommand, c.pauseCommand,
|
||
c.nextTrackCommand, c.previousTrackCommand, c.changePlaybackPositionCommand]
|
||
}
|
||
|
||
private func artwork(_ path: String?) -> MPMediaItemArtwork? {
|
||
guard let path, !path.isEmpty else { return nil }
|
||
if let cached = artworkCache, cached.path == path { return cached.art }
|
||
guard let img = NSImage(contentsOfFile: path) else { return nil }
|
||
let art = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
|
||
artworkCache = (path, art)
|
||
return art
|
||
}
|
||
#endif
|
||
|
||
// MARK: volume media keys (F10/F11 — system-defined, not MPRemoteCommandCenter)
|
||
|
||
#if canImport(AppKit)
|
||
private func startVolumeKeyMonitor() {
|
||
stopVolumeKeyMonitor()
|
||
volumeKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .systemDefined) { [weak self] event in
|
||
guard let self, self.volumeKeysEnabled, self.handleVolumeKey(event) else { return event }
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func stopVolumeKeyMonitor() {
|
||
if let m = volumeKeyMonitor {
|
||
NSEvent.removeMonitor(m)
|
||
volumeKeyMonitor = nil
|
||
}
|
||
}
|
||
|
||
/// True when the event was a volume key we forwarded (caller should consume it).
|
||
private func handleVolumeKey(_ event: NSEvent) -> Bool {
|
||
guard let h = volumeHandlers else { return false }
|
||
guard event.type == .systemDefined,
|
||
event.subtype.rawValue == NX_SUBTYPE_AUX_CONTROL_BUTTONS else { return false }
|
||
let data = event.data1
|
||
let keyCode = Int((data & 0xFFFF_0000) >> 16)
|
||
let keyFlags = Int(data & 0x0000_FFFF)
|
||
let keyState = (keyFlags & 0xFF00) >> 8
|
||
guard keyState == 0x0A else { return false } // key-down only
|
||
switch keyCode {
|
||
case Int(NX_KEYTYPE_SOUND_UP): return h.volumeUp()
|
||
case Int(NX_KEYTYPE_SOUND_DOWN): return h.volumeDown()
|
||
default: return false
|
||
}
|
||
}
|
||
#endif
|
||
} |