hudini/Sources/HUDini/MediaKeyMonitor.swift

215 lines
6.9 KiB
Swift
Raw Permalink Normal View History

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