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