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:
johnfrum1234 2026-03-10 14:07:09 +02:00
parent de21639c24
commit 056c79d15e
4 changed files with 600 additions and 7 deletions

View file

@ -4,35 +4,70 @@ import SwiftUI
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private var volumeMonitor: VolumeMonitor!
private var mediaKeyMonitor: MediaKeyMonitor!
private var hudPanel: HUDPanel!
private var prefsWindow: NSWindow?
func applicationDidFinishLaunching(_ notification: Notification) {
// Hide from Dock
NSApp.setActivationPolicy(.accessory)
setupStatusBar()
setupHUD()
setupVolumeMonitor()
setupMediaKeyMonitor()
}
private func updateStatusIcon(volume: Float, isMuted: Bool) {
guard let button = statusItem?.button else { return }
let iconName: String
if isMuted || volume == 0 {
iconName = "speaker.slash.fill"
} else if volume < 0.33 {
iconName = "speaker.wave.1.fill"
} else if volume < 0.66 {
iconName = "speaker.wave.2.fill"
} else {
iconName = "speaker.wave.3.fill"
}
button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "HUDini")
button.image?.size = NSSize(width: 18, height: 18)
}
private func setupStatusBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
// Set initial icon based on current volume
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "speaker.wave.2.fill", accessibilityDescription: "HUDini")
button.image?.size = NSSize(width: 18, height: 18)
}
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "HUDini", action: nil, keyEquivalent: ""))
let titleItem = NSMenuItem(title: "HUDini", action: nil, keyEquivalent: "")
menu.addItem(titleItem)
menu.addItem(NSMenuItem.separator())
let enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled(_:)), keyEquivalent: "e")
enabledItem.state = .on
enabledItem.target = self
menu.addItem(enabledItem)
let hudItem = NSMenuItem(title: "Volume HUD", action: #selector(toggleHUD(_:)), keyEquivalent: "")
hudItem.state = .on
hudItem.target = self
hudItem.tag = 100
menu.addItem(hudItem)
let mediaItem = NSMenuItem(title: "Play Key Launch", action: #selector(toggleMediaKeys(_:)), keyEquivalent: "")
mediaItem.state = .on
mediaItem.target = self
mediaItem.tag = 200
menu.addItem(mediaItem)
menu.addItem(NSMenuItem.separator())
let prefsItem = NSMenuItem(title: "Preferences...", action: #selector(openPreferences), keyEquivalent: ",")
prefsItem.target = self
menu.addItem(prefsItem)
menu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
@ -48,12 +83,30 @@ class AppDelegate: NSObject, NSApplicationDelegate {
volumeMonitor = VolumeMonitor { [weak self] volume, isMuted in
DispatchQueue.main.async {
self?.hudPanel.show(volume: volume, isMuted: isMuted)
self?.updateStatusIcon(volume: volume, isMuted: isMuted)
}
}
volumeMonitor.start()
// Set initial icon from current volume
let vol = volumeMonitor.getVolume()
let muted = volumeMonitor.isMuted()
updateStatusIcon(volume: vol, isMuted: muted)
}
@objc private func toggleEnabled(_ sender: NSMenuItem) {
private func setupMediaKeyMonitor() {
mediaKeyMonitor = MediaKeyMonitor()
let ok = mediaKeyMonitor.start()
if !ok {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
_ = self?.mediaKeyMonitor.start()
}
}
}
// MARK: - Actions
@objc private func toggleHUD(_ sender: NSMenuItem) {
if sender.state == .on {
sender.state = .off
volumeMonitor.stop()
@ -63,8 +116,65 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
@objc private func toggleMediaKeys(_ sender: NSMenuItem) {
if sender.state == .on {
sender.state = .off
mediaKeyMonitor.isEnabled = false
} else {
sender.state = .on
mediaKeyMonitor.isEnabled = true
}
}
@objc private func openPreferences() {
if let window = prefsWindow {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let prefsView = PreferencesView(
currentBundleID: mediaKeyMonitor.preferredBundleID,
startAtLogin: LoginItemManager.isEnabled,
onSave: { [weak self] bundleID in
self?.mediaKeyMonitor.preferredBundleID = bundleID
},
onStartAtLoginChanged: { enabled in
// Use the .app bundle path from current process
let appPath = Bundle.main.bundlePath
// If running from .build, try to find the .app bundle
let finalPath: String
if appPath.contains(".build") {
let projectDir = (appPath as NSString)
.components(separatedBy: ".build").first ?? ""
let bundlePath = projectDir + "HUDini.app"
finalPath = FileManager.default.fileExists(atPath: bundlePath)
? bundlePath : "/Applications/HUDini.app"
} else {
finalPath = appPath
}
LoginItemManager.setEnabled(enabled, appPath: finalPath)
}
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "HUDini"
window.contentView = NSHostingView(rootView: prefsView)
window.center()
window.isReleasedWhenClosed = false
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
prefsWindow = window
}
@objc private func quit() {
volumeMonitor.stop()
mediaKeyMonitor.stop()
NSApp.terminate(nil)
}
}

View file

@ -0,0 +1,41 @@
import Foundation
struct LoginItemManager {
private static let plistPath: String = {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return "\(home)/Library/LaunchAgents/com.grosfrumos.hudini.plist"
}()
static var isEnabled: Bool {
FileManager.default.fileExists(atPath: plistPath)
}
static func setEnabled(_ enabled: Bool, appPath: String? = nil) {
if enabled {
let path = appPath ?? Bundle.main.bundlePath
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.grosfrumos.hudini</string>
<key>ProgramArguments</key>
<array>
<string>open</string>
<string>\(path)</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
"""
// Ensure LaunchAgents dir exists
let dir = (plistPath as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
try? plist.write(toFile: plistPath, atomically: true, encoding: .utf8)
} else {
try? FileManager.default.removeItem(atPath: plistPath)
}
}
}

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

View file

@ -0,0 +1,228 @@
import SwiftUI
import AppKit
// NSImage wrapper that actually renders properly in SwiftUI
struct AppIconView: NSViewRepresentable {
let nsImage: NSImage
func makeNSView(context: Context) -> NSImageView {
let view = NSImageView()
view.imageScaling = .scaleProportionallyUpOrDown
view.image = nsImage
return view
}
func updateNSView(_ nsView: NSImageView, context: Context) {
nsView.image = nsImage
}
}
struct PreferencesView: View {
@State private var selectedBundleID: String
@State private var customAppName: String?
@State private var customAppIcon: NSImage?
@State private var startAtLogin: Bool
let onSave: (String) -> Void
let onStartAtLoginChanged: (Bool) -> Void
init(
currentBundleID: String,
startAtLogin: Bool,
onSave: @escaping (String) -> Void,
onStartAtLoginChanged: @escaping (Bool) -> Void
) {
_selectedBundleID = State(initialValue: currentBundleID)
_startAtLogin = State(initialValue: startAtLogin)
self.onSave = onSave
self.onStartAtLoginChanged = onStartAtLoginChanged
if !MediaPlayer.known.contains(where: { $0.bundleID == currentBundleID }) {
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: currentBundleID) {
_customAppName = State(initialValue: url.deletingPathExtension().lastPathComponent)
_customAppIcon = State(initialValue: NSWorkspace.shared.icon(forFile: url.path))
}
}
}
var body: some View {
VStack(spacing: 0) {
Text("HUDini Preferences")
.font(.headline)
.padding(.top, 16)
.padding(.bottom, 12)
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Launch on Play key:")
.font(.subheadline)
.foregroundColor(.secondary)
VStack(spacing: 0) {
ForEach(Array(MediaPlayer.known.enumerated()), id: \.element.bundleID) { index, player in
PlayerRow(
player: player,
isSelected: selectedBundleID == player.bundleID,
isInstalled: NSWorkspace.shared.urlForApplication(
withBundleIdentifier: player.bundleID) != nil
) {
selectedBundleID = player.bundleID
customAppName = nil
customAppIcon = nil
onSave(player.bundleID)
}
if index < MediaPlayer.known.count - 1 {
Divider().padding(.leading, 46)
}
}
if let name = customAppName {
Divider().padding(.leading, 46)
CustomPlayerRow(
name: name,
icon: customAppIcon,
isSelected: !MediaPlayer.known.contains(where: { $0.bundleID == selectedBundleID })
) {
onSave(selectedBundleID)
}
}
}
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)
)
Button("Choose Other App...") {
chooseCustomApp()
}
.padding(.top, 4)
}
.padding(16)
Divider()
HStack {
Toggle("Start at login", isOn: Binding(
get: { startAtLogin },
set: { newValue in
startAtLogin = newValue
onStartAtLoginChanged(newValue)
}
))
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Spacer()
}
.frame(width: 320, height: 440)
}
private func chooseCustomApp() {
let panel = NSOpenPanel()
panel.title = "Choose Application"
panel.allowedContentTypes = [.application]
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url,
let bundle = Bundle(url: url), let bid = bundle.bundleIdentifier else { return }
selectedBundleID = bid
customAppName = url.deletingPathExtension().lastPathComponent
customAppIcon = NSWorkspace.shared.icon(forFile: url.path)
onSave(bid)
}
}
struct PlayerRow: View {
let player: MediaPlayer
let isSelected: Bool
let isInstalled: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 10) {
iconView
.frame(width: 30, height: 30)
Text(player.name)
.foregroundColor(isInstalled ? .primary : .secondary)
Spacer()
if !isInstalled {
Text("not installed")
.font(.caption)
.foregroundColor(.secondary)
}
if isSelected {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.fontWeight(.semibold)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
}
@ViewBuilder
private var iconView: some View {
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: player.bundleID) {
AppIconView(nsImage: NSWorkspace.shared.icon(forFile: url.path))
} else {
Image(systemName: "music.note.house")
.font(.system(size: 20))
.foregroundColor(.secondary)
.frame(width: 30, height: 30)
}
}
}
struct CustomPlayerRow: View {
let name: String
let icon: NSImage?
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 10) {
if let icon = icon {
AppIconView(nsImage: icon)
.frame(width: 30, height: 30)
} else {
Image(systemName: "app.fill")
.font(.system(size: 20))
.foregroundColor(.secondary)
.frame(width: 30, height: 30)
}
Text(name)
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.fontWeight(.semibold)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
}
}