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.effectivePlaying) 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 } }