MediaKeyHandler.swift 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import AppKit
  2. import Foundation
  3. import MediaPlayer
  4. /// Integrates with macOS media keys (play/pause, next, previous) and Now Playing info center.
  5. /// This makes MixBoard respond to keyboard media keys and appear in the Now Playing widget.
  6. @MainActor
  7. final class MediaKeyHandler {
  8. static let shared = MediaKeyHandler()
  9. private let commandCenter = MPRemoteCommandCenter.shared()
  10. private let infoCenter = MPNowPlayingInfoCenter.default()
  11. private var keyMonitor: Any?
  12. private var doubleClickMonitor: Any?
  13. weak var playerVM: PlayerViewModel?
  14. private init() {}
  15. /// Start handling media key events. Call once at app launch.
  16. func register(playerVM: PlayerViewModel) {
  17. self.playerVM = playerVM
  18. // Play
  19. commandCenter.playCommand.isEnabled = true
  20. commandCenter.playCommand.addTarget { [weak self] _ in
  21. guard let self, let vm = self.playerVM else { return .commandFailed }
  22. if !vm.isPlaying {
  23. vm.togglePlayPause()
  24. }
  25. return .success
  26. }
  27. // Pause
  28. commandCenter.pauseCommand.isEnabled = true
  29. commandCenter.pauseCommand.addTarget { [weak self] _ in
  30. guard let self, let vm = self.playerVM else { return .commandFailed }
  31. if vm.isPlaying {
  32. vm.togglePlayPause()
  33. }
  34. return .success
  35. }
  36. // Toggle play/pause
  37. commandCenter.togglePlayPauseCommand.isEnabled = true
  38. commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
  39. guard let self, let vm = self.playerVM else { return .commandFailed }
  40. vm.togglePlayPause()
  41. return .success
  42. }
  43. // Next track
  44. commandCenter.nextTrackCommand.isEnabled = true
  45. commandCenter.nextTrackCommand.addTarget { [weak self] _ in
  46. guard let self, let vm = self.playerVM else { return .commandFailed }
  47. vm.playNext()
  48. return .success
  49. }
  50. // Previous track
  51. commandCenter.previousTrackCommand.isEnabled = true
  52. commandCenter.previousTrackCommand.addTarget { [weak self] _ in
  53. guard let self, let vm = self.playerVM else { return .commandFailed }
  54. vm.playPrevious()
  55. return .success
  56. }
  57. // Seek (for scrubbing via Touch Bar or control center)
  58. commandCenter.changePlaybackPositionCommand.isEnabled = true
  59. commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
  60. guard let self, let vm = self.playerVM,
  61. let posEvent = event as? MPChangePlaybackPositionCommandEvent else {
  62. return .commandFailed
  63. }
  64. vm.seek(to: posEvent.positionTime)
  65. return .success
  66. }
  67. // Spacebar → play/pause (when no text field is focused)
  68. keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  69. // Only handle bare spacebar (keyCode 49), no modifiers
  70. guard event.keyCode == 49,
  71. event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] else {
  72. return event
  73. }
  74. // Don't intercept if a text field / search field is the first responder
  75. if let responder = NSApp.keyWindow?.firstResponder,
  76. responder is NSTextView || responder is NSTextField {
  77. return event
  78. }
  79. self?.playerVM?.togglePlayPause()
  80. return nil // consume the event
  81. }
  82. // Double-click on playlist row → play that track (like foobar2000)
  83. doubleClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  84. guard event.clickCount == 2 else { return event }
  85. // Post notification — PlaylistEntryList will handle it
  86. NotificationCenter.default.post(name: .doubleClickPlayTrack, object: nil)
  87. return event
  88. }
  89. }
  90. /// Update the Now Playing info with current track metadata.
  91. func updateNowPlaying(track: Track?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) {
  92. guard let track else {
  93. infoCenter.nowPlayingInfo = nil
  94. return
  95. }
  96. var info = [String: Any]()
  97. info[MPMediaItemPropertyTitle] = track.title
  98. info[MPMediaItemPropertyArtist] = track.artist
  99. info[MPMediaItemPropertyAlbumTitle] = track.album
  100. info[MPMediaItemPropertyPlaybackDuration] = duration
  101. info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
  102. info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
  103. if let bpm = track.bpm {
  104. info[MPMediaItemPropertyBeatsPerMinute] = Int(bpm)
  105. }
  106. infoCenter.nowPlayingInfo = info
  107. infoCenter.playbackState = isPlaying ? .playing : .paused
  108. }
  109. }