diff --git a/Sources/HUDini/AppDelegate.swift b/Sources/HUDini/AppDelegate.swift
index fb055f0..c7d6836 100644
--- a/Sources/HUDini/AppDelegate.swift
+++ b/Sources/HUDini/AppDelegate.swift
@@ -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)
}
}
diff --git a/Sources/HUDini/LoginItemManager.swift b/Sources/HUDini/LoginItemManager.swift
new file mode 100644
index 0000000..230453e
--- /dev/null
+++ b/Sources/HUDini/LoginItemManager.swift
@@ -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 = """
+
+
+
+
+ Label
+ com.grosfrumos.hudini
+ ProgramArguments
+
+ open
+ \(path)
+
+ RunAtLoad
+
+
+
+ """
+ // 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)
+ }
+ }
+}
diff --git a/Sources/HUDini/MediaKeyMonitor.swift b/Sources/HUDini/MediaKeyMonitor.swift
new file mode 100644
index 0000000..1f6feb6
--- /dev/null
+++ b/Sources/HUDini/MediaKeyMonitor.swift
@@ -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 = 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? {
+ guard let userInfo = userInfo else { return Unmanaged.passRetained(event) }
+ let monitor = Unmanaged.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)
+}
+
diff --git a/Sources/HUDini/PreferencesView.swift b/Sources/HUDini/PreferencesView.swift
new file mode 100644
index 0000000..48895fe
--- /dev/null
+++ b/Sources/HUDini/PreferencesView.swift
@@ -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)
+ }
+}