tv-anarchy/Sources/TVAnarchyCore/NowPlayingController.swift

126 lines
5.5 KiB
Swift
Raw 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
#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. 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
}