import AppKit import Foundation import MediaPlayer /// Integrates with macOS media keys (play/pause, next, previous) and Now Playing info center. /// This makes MixBoard respond to keyboard media keys and appear in the Now Playing widget. @MainActor final class MediaKeyHandler { static let shared = MediaKeyHandler() private let commandCenter = MPRemoteCommandCenter.shared() private let infoCenter = MPNowPlayingInfoCenter.default() private var keyMonitor: Any? private var doubleClickMonitor: Any? weak var playerVM: PlayerViewModel? private init() {} /// Start handling media key events. Call once at app launch. func register(playerVM: PlayerViewModel) { self.playerVM = playerVM // Play commandCenter.playCommand.isEnabled = true commandCenter.playCommand.addTarget { [weak self] _ in guard let self, let vm = self.playerVM else { return .commandFailed } if !vm.isPlaying { vm.togglePlayPause() } return .success } // Pause commandCenter.pauseCommand.isEnabled = true commandCenter.pauseCommand.addTarget { [weak self] _ in guard let self, let vm = self.playerVM else { return .commandFailed } if vm.isPlaying { vm.togglePlayPause() } return .success } // Toggle play/pause commandCenter.togglePlayPauseCommand.isEnabled = true commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in guard let self, let vm = self.playerVM else { return .commandFailed } vm.togglePlayPause() return .success } // Next track commandCenter.nextTrackCommand.isEnabled = true commandCenter.nextTrackCommand.addTarget { [weak self] _ in guard let self, let vm = self.playerVM else { return .commandFailed } vm.playNext() return .success } // Previous track commandCenter.previousTrackCommand.isEnabled = true commandCenter.previousTrackCommand.addTarget { [weak self] _ in guard let self, let vm = self.playerVM else { return .commandFailed } vm.playPrevious() return .success } // Seek (for scrubbing via Touch Bar or control center) commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let self, let vm = self.playerVM, let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } vm.seek(to: posEvent.positionTime) return .success } // Spacebar → play/pause (when no text field is focused) keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in // Only handle bare spacebar (keyCode 49), no modifiers guard event.keyCode == 49, event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] else { return event } // Don't intercept if a text field / search field is the first responder if let responder = NSApp.keyWindow?.firstResponder, responder is NSTextView || responder is NSTextField { return event } self?.playerVM?.togglePlayPause() return nil // consume the event } // Double-click on playlist row → play that track (like foobar2000) doubleClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in guard event.clickCount == 2 else { return event } // Post notification — PlaylistEntryList will handle it NotificationCenter.default.post(name: .doubleClickPlayTrack, object: nil) return event } } /// Update the Now Playing info with current track metadata. func updateNowPlaying(track: Track?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) { guard let track else { infoCenter.nowPlayingInfo = nil return } var info = [String: Any]() info[MPMediaItemPropertyTitle] = track.title info[MPMediaItemPropertyArtist] = track.artist info[MPMediaItemPropertyAlbumTitle] = track.album info[MPMediaItemPropertyPlaybackDuration] = duration info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 if let bpm = track.bpm { info[MPMediaItemPropertyBeatsPerMinute] = Int(bpm) } infoCenter.nowPlayingInfo = info infoCenter.playbackState = isPlaying ? .playing : .paused } }