| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- import AppKit
- import SwiftUI
- // MARK: - Shortcut Action
- /// All configurable keyboard shortcut actions in MixBoard.
- enum ShortcutAction: String, CaseIterable, Identifiable, Codable {
- // Playback
- case playPause = "Play / Pause"
- case stop = "Stop"
- case nextTrack = "Next Track"
- case previousTrack = "Previous Track"
- case skipForward = "Skip Forward 10s"
- case skipBackward = "Skip Backward 10s"
- case toggleShuffle = "Toggle Shuffle"
- case toggleRepeat = "Toggle Repeat"
- // Mix
- case addToMix1 = "Add to Mix 1"
- case addToMix2 = "Add to Mix 2"
- case addToMix3 = "Add to Mix 3"
- // General
- case nowPlaying = "Now Playing"
- case search = "Search All Playlists"
- case newPlaylist = "New Playlist"
- case exportToDAW = "Export to DAW"
- case importFromiPhone = "Import from iPhone"
- var id: String { rawValue }
- var group: ShortcutGroup {
- switch self {
- case .playPause, .stop, .nextTrack, .previousTrack,
- .skipForward, .skipBackward, .toggleShuffle, .toggleRepeat:
- return .playback
- case .addToMix1, .addToMix2, .addToMix3:
- return .mix
- case .nowPlaying, .search, .newPlaylist, .exportToDAW, .importFromiPhone:
- return .general
- }
- }
- }
- /// Grouping for display in the Shortcuts settings tab.
- enum ShortcutGroup: String, CaseIterable {
- case playback = "Playback"
- case mix = "Mix"
- case general = "General"
- var actions: [ShortcutAction] {
- ShortcutAction.allCases.filter { $0.group == self }
- }
- }
- // MARK: - Shortcut Binding
- /// A stored keyboard shortcut: key + modifier flags.
- struct ShortcutBinding: Codable, Equatable {
- var key: String // Character or special key: "space", "right", "left", etc.
- var command: Bool = false
- var shift: Bool = false
- var option: Bool = false
- var control: Bool = false
- /// Convert to SwiftUI `KeyEquivalent`.
- var keyEquivalent: KeyEquivalent {
- switch key.lowercased() {
- case "space": return .space
- case "right": return .rightArrow
- case "left": return .leftArrow
- case "up": return .upArrow
- case "down": return .downArrow
- case "return": return .return
- case "tab": return .tab
- case "delete": return .delete
- case "escape": return .escape
- default:
- if let char = key.first {
- return KeyEquivalent(char)
- }
- return KeyEquivalent("?")
- }
- }
- /// Convert to SwiftUI `EventModifiers`.
- var eventModifiers: EventModifiers {
- var mods: EventModifiers = []
- if command { mods.insert(.command) }
- if shift { mods.insert(.shift) }
- if option { mods.insert(.option) }
- if control { mods.insert(.control) }
- return mods
- }
- /// Human-readable display string, e.g. "⇧⌘1".
- var displayString: String {
- var parts: [String] = []
- if control { parts.append("⌃") }
- if option { parts.append("⌥") }
- if shift { parts.append("⇧") }
- if command { parts.append("⌘") }
- let keyDisplay: String
- switch key.lowercased() {
- case "space": keyDisplay = "Space"
- case "right": keyDisplay = "→"
- case "left": keyDisplay = "←"
- case "up": keyDisplay = "↑"
- case "down": keyDisplay = "↓"
- case "return": keyDisplay = "↩"
- case "tab": keyDisplay = "⇥"
- case "delete": keyDisplay = "⌫"
- case "escape": keyDisplay = "⎋"
- default: keyDisplay = key.uppercased()
- }
- parts.append(keyDisplay)
- return parts.joined()
- }
- /// Build a `ShortcutBinding` from an `NSEvent`.
- static func from(_ event: NSEvent) -> ShortcutBinding {
- let key: String
- switch event.keyCode {
- case 49: key = "space"
- case 124: key = "right"
- case 123: key = "left"
- case 126: key = "up"
- case 125: key = "down"
- case 36: key = "return"
- case 48: key = "tab"
- case 51: key = "delete"
- default:
- key = event.charactersIgnoringModifiers?.lowercased() ?? "?"
- }
- let mods = event.modifierFlags
- return ShortcutBinding(
- key: key,
- command: mods.contains(.command),
- shift: mods.contains(.shift),
- option: mods.contains(.option),
- control: mods.contains(.control)
- )
- }
- }
- // MARK: - Config Singleton
- /// Manages all keyboard shortcut bindings, persisted to UserDefaults.
- final class KeyboardShortcutConfig: ObservableObject {
- static let shared = KeyboardShortcutConfig()
- @Published var shortcuts: [ShortcutAction: ShortcutBinding] {
- didSet { save() }
- }
- /// Factory defaults.
- static let defaultShortcuts: [ShortcutAction: ShortcutBinding] = [
- .playPause: ShortcutBinding(key: "space"),
- .stop: ShortcutBinding(key: ".", command: true),
- .nextTrack: ShortcutBinding(key: "right", command: true),
- .previousTrack: ShortcutBinding(key: "left", command: true),
- .skipForward: ShortcutBinding(key: "right", command: true, shift: true),
- .skipBackward: ShortcutBinding(key: "left", command: true, shift: true),
- .toggleShuffle: ShortcutBinding(key: "s", command: true, shift: true),
- .toggleRepeat: ShortcutBinding(key: "r", command: true, shift: true),
- .addToMix1: ShortcutBinding(key: "1", command: true),
- .addToMix2: ShortcutBinding(key: "2", command: true),
- .addToMix3: ShortcutBinding(key: "3", command: true),
- .nowPlaying: ShortcutBinding(key: "p", command: true, shift: true),
- .search: ShortcutBinding(key: "f", command: true),
- .newPlaylist: ShortcutBinding(key: "n", command: true, shift: true),
- .exportToDAW: ShortcutBinding(key: "e", command: true),
- .importFromiPhone: ShortcutBinding(key: "i", command: true, shift: true),
- ]
- init() {
- shortcuts = Self.load()
- }
- /// Get the binding for an action (with fallback to default).
- func binding(for action: ShortcutAction) -> ShortcutBinding {
- shortcuts[action] ?? Self.defaultShortcuts[action]!
- }
- func resetToDefaults() {
- shortcuts = Self.defaultShortcuts
- }
- // MARK: - Persistence
- private func save() {
- let stringKeyed = Dictionary(uniqueKeysWithValues: shortcuts.map { ($0.key.rawValue, $0.value) })
- if let data = try? JSONEncoder().encode(stringKeyed) {
- UserDefaults.standard.set(data, forKey: "keyboardShortcuts")
- }
- }
- private static func load() -> [ShortcutAction: ShortcutBinding] {
- guard let data = UserDefaults.standard.data(forKey: "keyboardShortcuts"),
- let stringKeyed = try? JSONDecoder().decode([String: ShortcutBinding].self, from: data) else {
- return defaultShortcuts
- }
- // Merge with defaults so new actions added in updates get their fallback
- var result = defaultShortcuts
- for (key, value) in stringKeyed {
- if let action = ShortcutAction(rawValue: key) {
- result[action] = value
- }
- }
- return result
- }
- }
|