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
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