215 lines
6.9 KiB
Swift
215 lines
6.9 KiB
Swift
|
|
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)
|
||
|
|
}
|
||
|
|
|