fix: respond to macOS system playback controls

This commit is contained in:
2026-10-11 01:38:37 +02:00
parent c6d2958744
commit cf24a0fc4f
5 changed files with 251 additions and 8 deletions
+130
View File
@@ -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
}
}
+22 -4
View File
@@ -48,6 +48,7 @@ final class PlayerModel: ObservableObject {
private var progressURL: URL?
private var resumePosition: Double?
private var lastSavedPosition: Double?
private var systemPlayback: SystemPlaybackControls?
init(defaults: UserDefaults = .standard) {
playbackProgress = PlaybackProgress(defaults: defaults)
@@ -65,6 +66,7 @@ final class PlayerModel: ObservableObject {
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.poll() }
}
systemPlayback = SystemPlaybackControls(model: self)
}
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
@@ -138,6 +140,7 @@ final class PlayerModel: ObservableObject {
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
systemPlayback?.update()
commandError = nil
revealControls()
youtubeTask = Task { [weak self] in
@@ -212,6 +215,7 @@ final class PlayerModel: ObservableObject {
window?.representedURL = url
window?.title = title
revealControls()
systemPlayback?.update()
}
func open(_ url: URL) {
@@ -236,10 +240,16 @@ final class PlayerModel: ObservableObject {
}
func togglePlayback() {
guard let handle, canTogglePlayback else { return }
let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle)
setPlayback(playing: state.status != PLAYER_PLAYING)
}
@discardableResult
func setPlayback(playing: Bool) -> Bool {
guard let handle, canTogglePlayback else { return false }
let accepted = playing ? player_play(handle) : player_pause(handle)
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
return accepted
}
func finishSeeking() {
@@ -275,10 +285,12 @@ final class PlayerModel: ObservableObject {
window?.representedURL = nil
window?.title = "terebi"
revealControls()
systemPlayback?.update()
}
func seekRelative(_ seconds: Double) {
guard canSeek, let handle else { return }
@discardableResult
func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false }
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = player_seek(handle, target)
if accepted {
@@ -287,6 +299,7 @@ final class PlayerModel: ObservableObject {
}
commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls()
return accepted
}
func stepFrame(_ direction: Int32) {
@@ -458,6 +471,7 @@ final class PlayerModel: ObservableObject {
guard next.revision != revision, next.status != PLAYER_OPENING else { return }
pendingRevision = nil
}
defer { systemPlayback?.update() }
let statusChanged = state.status != next.status
state = next
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
@@ -496,6 +510,8 @@ final class PlayerModel: ObservableObject {
func shutdown() {
saveProgress()
cancelYouTubeResolution()
systemPlayback?.shutdown()
systemPlayback = nil
compactWindow.restore(resumeFullScreen: false)
timer?.invalidate()
timer = nil
@@ -1151,7 +1167,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
#if !PLAYER_CHECK
@main
#endif
@MainActor
struct TerebiApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate