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