126 lines
5.5 KiB
Swift
126 lines
5.5 KiB
Swift
import Foundation
|
||
#if canImport(MediaPlayer)
|
||
import MediaPlayer
|
||
#endif
|
||
#if canImport(AppKit)
|
||
import AppKit
|
||
#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. Gated
|
||
/// by `AppSettings.forwardMediaKeys` (Part F) — when off, nothing is registered,
|
||
/// so the media keys fall through to other apps.
|
||
@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
|
||
public init(toggle: @escaping () -> Void, play: @escaping () -> Void,
|
||
pause: @escaping () -> Void, next: @escaping () -> Void,
|
||
previous: @escaping () -> Void, seek: @escaping (Double) -> Void) {
|
||
self.toggle = toggle; self.play = play; self.pause = pause
|
||
self.next = next; self.previous = previous; self.seek = seek
|
||
}
|
||
}
|
||
|
||
public private(set) var isEnabled = false
|
||
#if canImport(MediaPlayer)
|
||
private var artworkCache: (path: String, art: MPMediaItemArtwork)?
|
||
#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
|
||
}
|
||
|
||
// MARK: registration
|
||
|
||
/// Register the system remote commands (idempotent). Safe to call repeatedly.
|
||
public func enable(_ 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: remove the command targets and clear Now Playing.
|
||
public func disable() {
|
||
#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
|
||
}
|
||
|
||
/// Push the current state to the system. No-op when 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
|
||
}
|