- 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>
180 lines
6 KiB
Swift
180 lines
6 KiB
Swift
import Cocoa
|
|
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) {
|
|
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()
|
|
|
|
let titleItem = NSMenuItem(title: "HUDini", action: nil, keyEquivalent: "")
|
|
menu.addItem(titleItem)
|
|
menu.addItem(NSMenuItem.separator())
|
|
|
|
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)
|
|
|
|
statusItem.menu = menu
|
|
}
|
|
|
|
private func setupHUD() {
|
|
hudPanel = HUDPanel()
|
|
}
|
|
|
|
private func setupVolumeMonitor() {
|
|
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)
|
|
}
|
|
|
|
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()
|
|
} else {
|
|
sender.state = .on
|
|
volumeMonitor.start()
|
|
}
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|