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:
parent
de21639c24
commit
056c79d15e
4 changed files with 600 additions and 7 deletions
|
|
@ -4,35 +4,70 @@ import SwiftUI
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private var statusItem: NSStatusItem!
|
private var statusItem: NSStatusItem!
|
||||||
private var volumeMonitor: VolumeMonitor!
|
private var volumeMonitor: VolumeMonitor!
|
||||||
|
private var mediaKeyMonitor: MediaKeyMonitor!
|
||||||
private var hudPanel: HUDPanel!
|
private var hudPanel: HUDPanel!
|
||||||
|
private var prefsWindow: NSWindow?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
// Hide from Dock
|
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
setupStatusBar()
|
setupStatusBar()
|
||||||
setupHUD()
|
setupHUD()
|
||||||
setupVolumeMonitor()
|
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() {
|
private func setupStatusBar() {
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
|
|
||||||
|
// Set initial icon based on current volume
|
||||||
if let button = statusItem.button {
|
if let button = statusItem.button {
|
||||||
button.image = NSImage(systemSymbolName: "speaker.wave.2.fill", accessibilityDescription: "HUDini")
|
button.image = NSImage(systemSymbolName: "speaker.wave.2.fill", accessibilityDescription: "HUDini")
|
||||||
button.image?.size = NSSize(width: 18, height: 18)
|
button.image?.size = NSSize(width: 18, height: 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
let menu = NSMenu()
|
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())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
let enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled(_:)), keyEquivalent: "e")
|
let hudItem = NSMenuItem(title: "Volume HUD", action: #selector(toggleHUD(_:)), keyEquivalent: "")
|
||||||
enabledItem.state = .on
|
hudItem.state = .on
|
||||||
enabledItem.target = self
|
hudItem.target = self
|
||||||
menu.addItem(enabledItem)
|
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())
|
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")
|
let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
|
||||||
quitItem.target = self
|
quitItem.target = self
|
||||||
menu.addItem(quitItem)
|
menu.addItem(quitItem)
|
||||||
|
|
@ -48,12 +83,30 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
volumeMonitor = VolumeMonitor { [weak self] volume, isMuted in
|
volumeMonitor = VolumeMonitor { [weak self] volume, isMuted in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.hudPanel.show(volume: volume, isMuted: isMuted)
|
self?.hudPanel.show(volume: volume, isMuted: isMuted)
|
||||||
|
self?.updateStatusIcon(volume: volume, isMuted: isMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
volumeMonitor.start()
|
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 {
|
if sender.state == .on {
|
||||||
sender.state = .off
|
sender.state = .off
|
||||||
volumeMonitor.stop()
|
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() {
|
@objc private func quit() {
|
||||||
volumeMonitor.stop()
|
volumeMonitor.stop()
|
||||||
|
mediaKeyMonitor.stop()
|
||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
Sources/HUDini/LoginItemManager.swift
Normal file
41
Sources/HUDini/LoginItemManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Sources/HUDini/MediaKeyMonitor.swift
Normal file
214
Sources/HUDini/MediaKeyMonitor.swift
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
228
Sources/HUDini/PreferencesView.swift
Normal file
228
Sources/HUDini/PreferencesView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue