tv-anarchy/Sources/TVAnarchyCore/NowPlayingController.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

214 lines
No EOL
8.8 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
#if canImport(MediaPlayer)
import MediaPlayer
#endif
#if canImport(AppKit)
import AppKit
import IOKit.hidsystem
#endif
/// Bridges the macOS system transport hardware media keys (F7F9), 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
}