Add media key interception, preferences UI, dynamic menu bar icon
- Play key (F8) launches preferred player when no player is running - Blocks Apple Music auto-launch (hide + forceTerminate, like noTunes) - Preferences window: player picker with app icons, "Choose Other App..." - Known players: Spotify, Apple Music, Yandex Music, VLC, IINA, Vox - Start at login via LaunchAgent - Menu bar icon dynamically reflects current volume level and mute state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de21639c24
commit
056c79d15e
4 changed files with 600 additions and 7 deletions
214
Sources/HUDini/MediaKeyMonitor.swift
Normal file
214
Sources/HUDini/MediaKeyMonitor.swift
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import Cocoa
|
||||
import CoreGraphics
|
||||
|
||||
struct MediaPlayer: Equatable {
|
||||
let name: String
|
||||
let bundleID: String
|
||||
|
||||
static let known: [MediaPlayer] = [
|
||||
MediaPlayer(name: "Spotify", bundleID: "com.spotify.client"),
|
||||
MediaPlayer(name: "Apple Music", bundleID: "com.apple.Music"),
|
||||
MediaPlayer(name: "Yandex Music", bundleID: "ru.yandex.music.mac.universal"),
|
||||
MediaPlayer(name: "VLC", bundleID: "org.videolan.vlc"),
|
||||
MediaPlayer(name: "IINA", bundleID: "com.colliderli.iina"),
|
||||
MediaPlayer(name: "Vox", bundleID: "com.coppertino.Vox"),
|
||||
]
|
||||
}
|
||||
|
||||
class MediaKeyMonitor {
|
||||
var eventTap: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
private var launchObserver: NSObjectProtocol?
|
||||
var isEnabled: Bool = true {
|
||||
didSet { isEnabled ? startWatching() : stopWatching() }
|
||||
}
|
||||
|
||||
private let prefsKey = "preferredMediaPlayer"
|
||||
private let appleMusicBundleID = "com.apple.Music"
|
||||
|
||||
// All known player bundle IDs for detection
|
||||
private let allPlayerBundleIDs: Set<String> = Set(MediaPlayer.known.map(\.bundleID) + [
|
||||
"com.apple.iTunes",
|
||||
"com.tidal.desktop",
|
||||
"com.amazon.music",
|
||||
"com.deezer.deezer-desktop",
|
||||
"com.plexamp.Plexamp",
|
||||
"app.foobar2000.mac",
|
||||
])
|
||||
|
||||
var preferredBundleID: String {
|
||||
get { UserDefaults.standard.string(forKey: prefsKey) ?? "com.spotify.client" }
|
||||
set { UserDefaults.standard.set(newValue, forKey: prefsKey) }
|
||||
}
|
||||
|
||||
func start() -> Bool {
|
||||
startWatching()
|
||||
startEventTap()
|
||||
return true
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopWatching()
|
||||
stopEventTap()
|
||||
}
|
||||
|
||||
// MARK: - App Launch Watcher (main mechanism, like noTunes)
|
||||
// Watches for Apple Music launching and kills it + launches preferred player
|
||||
|
||||
private func startWatching() {
|
||||
guard launchObserver == nil else { return }
|
||||
|
||||
// Kill Apple Music if it's already running and we don't want it
|
||||
if preferredBundleID != appleMusicBundleID {
|
||||
killAppleMusic()
|
||||
}
|
||||
|
||||
launchObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didLaunchApplicationNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
self?.handleAppLaunch(notification)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopWatching() {
|
||||
if let observer = launchObserver {
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(observer)
|
||||
launchObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAppLaunch(_ notification: Notification) {
|
||||
guard isEnabled else { return }
|
||||
guard preferredBundleID != appleMusicBundleID else { return }
|
||||
|
||||
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||
app.bundleIdentifier == appleMusicBundleID else { return }
|
||||
|
||||
// Hide immediately to prevent visual flash, then force-kill
|
||||
app.hide()
|
||||
app.forceTerminate()
|
||||
|
||||
// Launch preferred player if not already running
|
||||
if !isPreferredPlayerRunning() {
|
||||
launchPreferredPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
private func killAppleMusic() {
|
||||
for app in NSWorkspace.shared.runningApplications {
|
||||
if app.bundleIdentifier == appleMusicBundleID {
|
||||
app.hide()
|
||||
app.forceTerminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CGEvent Tap (supplementary — intercepts key before rcd when possible)
|
||||
|
||||
private func startEventTap() {
|
||||
guard eventTap == nil else { return }
|
||||
|
||||
let trusted = AXIsProcessTrusted()
|
||||
if !trusted {
|
||||
promptAccessibility()
|
||||
return
|
||||
}
|
||||
|
||||
let mask = CGEventMask(1 << CGEventType(rawValue: 14)!.rawValue) // NX_SYSDEFINED
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: mask,
|
||||
callback: mediaKeyCallback,
|
||||
userInfo: Unmanaged.passUnretained(self).toOpaque()
|
||||
) else { return }
|
||||
|
||||
eventTap = tap
|
||||
runLoopSource = CFMachPortCreateRunLoopSource(nil, tap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
}
|
||||
|
||||
private func stopEventTap() {
|
||||
if let source = runLoopSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
|
||||
runLoopSource = nil
|
||||
}
|
||||
if let tap = eventTap {
|
||||
CGEvent.tapEnable(tap: tap, enable: false)
|
||||
eventTap = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func isAnyPlayerRunning() -> Bool {
|
||||
NSWorkspace.shared.runningApplications.contains { app in
|
||||
guard let bid = app.bundleIdentifier else { return false }
|
||||
return allPlayerBundleIDs.contains(bid)
|
||||
}
|
||||
}
|
||||
|
||||
private func isPreferredPlayerRunning() -> Bool {
|
||||
NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == preferredBundleID }
|
||||
}
|
||||
|
||||
func launchPreferredPlayer() {
|
||||
guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: preferredBundleID) else { return }
|
||||
NSWorkspace.shared.openApplication(at: url, configuration: .init()) { _, _ in }
|
||||
}
|
||||
|
||||
private func promptAccessibility() {
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
||||
AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
}
|
||||
|
||||
// NX_KEYTYPE constants
|
||||
private let NX_KEYTYPE_PLAY: Int32 = 16
|
||||
|
||||
private func mediaKeyCallback(
|
||||
proxy: CGEventTapProxy,
|
||||
type: CGEventType,
|
||||
event: CGEvent,
|
||||
userInfo: UnsafeMutableRawPointer?
|
||||
) -> Unmanaged<CGEvent>? {
|
||||
guard let userInfo = userInfo else { return Unmanaged.passRetained(event) }
|
||||
let monitor = Unmanaged<MediaKeyMonitor>.fromOpaque(userInfo).takeUnretainedValue()
|
||||
|
||||
// Re-enable if system disabled us
|
||||
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
||||
if let tap = monitor.eventTap {
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
}
|
||||
return Unmanaged.passRetained(event)
|
||||
}
|
||||
|
||||
guard monitor.isEnabled else { return Unmanaged.passRetained(event) }
|
||||
guard type.rawValue == 14 else { return Unmanaged.passRetained(event) }
|
||||
|
||||
guard let nsEvent = NSEvent(cgEvent: event) else { return Unmanaged.passRetained(event) }
|
||||
guard nsEvent.subtype.rawValue == 8 else { return Unmanaged.passRetained(event) }
|
||||
|
||||
let data1 = nsEvent.data1
|
||||
let keyCode = Int32((data1 & 0xFFFF0000) >> 16)
|
||||
let keyDown = ((data1 & 0x0100) == 0)
|
||||
|
||||
guard keyCode == NX_KEYTYPE_PLAY && keyDown else {
|
||||
return Unmanaged.passRetained(event)
|
||||
}
|
||||
|
||||
// Play pressed — if no player running, launch preferred and consume
|
||||
if !monitor.isAnyPlayerRunning() {
|
||||
DispatchQueue.main.async {
|
||||
monitor.launchPreferredPlayer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return Unmanaged.passRetained(event)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue