fix: respond to macOS system playback controls
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import AppKit
|
||||
import MediaPlayer
|
||||
|
||||
@MainActor
|
||||
final class SystemPlaybackControls {
|
||||
private enum Action: Sendable {
|
||||
case play, pause, toggle, skip(Double)
|
||||
}
|
||||
|
||||
private struct Snapshot: Equatable {
|
||||
let title: String
|
||||
let duration: Double
|
||||
let second: Double
|
||||
let rate: Double
|
||||
let playbackState: MPNowPlayingPlaybackState
|
||||
let video: Bool
|
||||
let live: Bool
|
||||
let seeking: Bool
|
||||
}
|
||||
|
||||
private weak var model: PlayerModel?
|
||||
private let center = MPRemoteCommandCenter.shared()
|
||||
private let infoCenter = MPNowPlayingInfoCenter.default()
|
||||
private var targets: [(MPRemoteCommand, Any)] = []
|
||||
private var snapshot: Snapshot?
|
||||
|
||||
init(model: PlayerModel) {
|
||||
self.model = model
|
||||
register(center.playCommand, action: .play)
|
||||
register(center.pauseCommand, action: .pause)
|
||||
register(center.togglePlayPauseCommand, action: .toggle)
|
||||
register(center.previousTrackCommand, action: .skip(-5))
|
||||
register(center.nextTrackCommand, action: .skip(5))
|
||||
center.skipBackwardCommand.preferredIntervals = [5]
|
||||
center.skipForwardCommand.preferredIntervals = [5]
|
||||
register(center.skipBackwardCommand, action: .skip(-5))
|
||||
register(center.skipForwardCommand, action: .skip(5))
|
||||
update()
|
||||
}
|
||||
|
||||
private func register(_ command: MPRemoteCommand, action: Action) {
|
||||
let handler: @Sendable (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus = { [weak self] event in
|
||||
var action = action
|
||||
if case let .skip(defaultInterval) = action,
|
||||
let event = event as? MPSkipIntervalCommandEvent {
|
||||
guard event.interval.isFinite, event.interval > 0 else { return .commandFailed }
|
||||
action = .skip(defaultInterval < 0 ? -event.interval : event.interval)
|
||||
}
|
||||
return self?.dispatch(action) ?? .noActionableNowPlayingItem
|
||||
}
|
||||
let target = command.addTarget(handler: handler)
|
||||
targets.append((command, target))
|
||||
}
|
||||
|
||||
// MediaPlayer callbacks are not guaranteed to run on the main thread.
|
||||
private nonisolated func dispatch(_ action: Action) -> MPRemoteCommandHandlerStatus {
|
||||
if Thread.isMainThread {
|
||||
return MainActor.assumeIsolated { perform(action) }
|
||||
}
|
||||
return DispatchQueue.main.sync { perform(action) }
|
||||
}
|
||||
|
||||
private func perform(_ action: Action) -> MPRemoteCommandHandlerStatus {
|
||||
guard let model, model.fileName != nil, model.canTogglePlayback else {
|
||||
return .noActionableNowPlayingItem
|
||||
}
|
||||
let accepted: Bool
|
||||
switch action {
|
||||
case .play: accepted = model.setPlayback(playing: true)
|
||||
case .pause: accepted = model.setPlayback(playing: false)
|
||||
case .toggle: accepted = model.setPlayback(playing: model.state.status != PLAYER_PLAYING)
|
||||
case let .skip(seconds): accepted = model.seekRelative(seconds)
|
||||
}
|
||||
return accepted ? .success : .commandFailed
|
||||
}
|
||||
|
||||
func update() {
|
||||
let ready = model?.fileName != nil && model?.canTogglePlayback == true
|
||||
let seekable = ready && model?.canSeek == true
|
||||
for (command, _) in targets {
|
||||
let playback = command === center.playCommand || command === center.pauseCommand ||
|
||||
command === center.togglePlayPauseCommand
|
||||
let enabled = playback ? ready : seekable
|
||||
if command.isEnabled != enabled { command.isEnabled = enabled }
|
||||
}
|
||||
guard ready, let model, let title = model.fileName else {
|
||||
if snapshot != nil {
|
||||
infoCenter.nowPlayingInfo = nil
|
||||
infoCenter.playbackState = .stopped
|
||||
snapshot = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
let state = model.state
|
||||
let position = state.position.isFinite ? max(0, state.position) : 0
|
||||
let playing = state.status == PLAYER_PLAYING
|
||||
let playbackState: MPNowPlayingPlaybackState = playing ? .playing :
|
||||
(state.status == PLAYER_ENDED ? .stopped : .paused)
|
||||
let rate = playing && !state.buffering && !state.seek_pending ? 1.0 : 0.0
|
||||
let next = Snapshot(title: title, duration: model.duration, second: floor(position),
|
||||
rate: rate, playbackState: playbackState, video: state.has_video,
|
||||
live: model.isLiveStream, seeking: state.seek_pending)
|
||||
// Publish at most once per media second unless playback/seek/source state changes.
|
||||
guard next != snapshot else { return }
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: title,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: rate,
|
||||
MPNowPlayingInfoPropertyMediaType:
|
||||
(state.has_video ? MPNowPlayingInfoMediaType.video : .audio).rawValue,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: model.isLiveStream,
|
||||
]
|
||||
if next.duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = next.duration }
|
||||
infoCenter.nowPlayingInfo = info
|
||||
if infoCenter.playbackState != playbackState { infoCenter.playbackState = playbackState }
|
||||
snapshot = next
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
for (command, target) in targets {
|
||||
command.removeTarget(target)
|
||||
command.isEnabled = false
|
||||
}
|
||||
targets.removeAll()
|
||||
infoCenter.nowPlayingInfo = nil
|
||||
infoCenter.playbackState = .stopped
|
||||
snapshot = nil
|
||||
model = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user