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