import AppKit import SwiftUI import TerebiConfiguration extension KeyChord { // Key codes identify native navigation keys; printable keys follow the active keyboard layout. private static let nativeKeys: [UInt16: String] = [ 36: "return", 48: "tab", 49: "space", 51: "backspace", 53: "escape", 76: "return", 115: "home", 116: "pageup", 117: "delete", 119: "end", 121: "pagedown", 123: "left", 124: "right", 125: "down", 126: "up" ] private static let functionKeys: [UInt32: String] = Dictionary(uniqueKeysWithValues: (1...35).map { (UInt32(NSF1FunctionKey) + UInt32($0 - 1), "f\($0)") }) static func from(_ event: NSEvent) -> KeyChord? { guard event.type == .keyDown, !event.modifierFlags.contains(.function) || nativeKeys[event.keyCode] != nil || event.charactersIgnoringModifiers?.unicodeScalars.first.map({ $0.value >= UInt32(NSF1FunctionKey) && $0.value <= UInt32(NSF35FunctionKey) }) == true else { return nil } var modifiers: KeyModifiers = [] if event.modifierFlags.contains(.command) { modifiers.insert(.command) } if event.modifierFlags.contains(.control) { modifiers.insert(.control) } if event.modifierFlags.contains(.option) { modifiers.insert(.option) } if event.modifierFlags.contains(.shift) { modifiers.insert(.shift) } if let key = nativeKeys[event.keyCode] { return KeyChord(key: key, modifiers: modifiers) } guard let text = event.charactersIgnoringModifiers, text.count == 1, let scalar = text.unicodeScalars.first else { return nil } if let key = functionKeys[scalar.value] { return KeyChord(key: key, modifiers: modifiers) } guard !CharacterSet.controlCharacters.contains(scalar), !(0xF700...0xF8FF).contains(scalar.value) else { return nil } return KeyChord(key: text.lowercased(), modifiers: modifiers) } var keyboardShortcut: KeyboardShortcut? { let equivalent: KeyEquivalent switch key { case "space": equivalent = .space case "escape": equivalent = .escape case "return": equivalent = .return case "tab": equivalent = .tab case "backspace": equivalent = .delete case "delete": equivalent = .deleteForward case "left": equivalent = .leftArrow case "right": equivalent = .rightArrow case "up": equivalent = .upArrow case "down": equivalent = .downArrow case "home": equivalent = .home case "end": equivalent = .end case "pageup": equivalent = .pageUp case "pagedown": equivalent = .pageDown default: if key.count == 1, let first = key.first { equivalent = KeyEquivalent(first) } else if key.hasPrefix("f"), let number = Int(key.dropFirst()), (1...35).contains(number) { equivalent = KeyEquivalent(Character(UnicodeScalar(UInt32(NSF1FunctionKey) + UInt32(number - 1))!)) } else { return nil } } var flags: EventModifiers = [] if modifiers.contains(.command) { flags.insert(.command) } if modifiers.contains(.control) { flags.insert(.control) } if modifiers.contains(.option) { flags.insert(.option) } if modifiers.contains(.shift) { flags.insert(.shift) } return KeyboardShortcut(equivalent, modifiers: flags) } private static let displayNames = [ "space": "Space", "escape": "⎋", "return": "↩", "tab": "⇥", "backspace": "⌫", "delete": "⌦", "left": "←", "right": "→", "up": "↑", "down": "↓", "home": "↖", "end": "↘", "pageup": "⇞", "pagedown": "⇟" ] var displayText: String { return (modifiers.contains(.control) ? "⌃" : "") + (modifiers.contains(.option) ? "⌥" : "") + (modifiers.contains(.shift) ? "⇧" : "") + (modifiers.contains(.command) ? "⌘" : "") + (Self.displayNames[key] ?? key.uppercased()) } } extension PlayerModel { private static let nativeNavigationKeys: Set = [ "space", "return", "escape", "left", "right", "up", "down", "home", "end", "pageup", "pagedown" ] // The local monitor calls this exact adapter; checks can supply real AppKit events. @discardableResult func handleKeyEvent(_ event: NSEvent, in window: NSWindow) -> Bool { guard let chord = KeyChord.from(event) else { return false } return handleKeyEvent(event, in: window, chord: chord) } private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool { guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false } if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openYoutube).contains(chord) { return perform(.openYoutube) } let responder = window.firstResponder if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false } if let text = responder as? NSTextField, text.isEditable || text.isSelectable { return false } // Lists and inert labels are playback surfaces, not text entry or activation controls. // Keep deliberate button/slider focus, traversal and VoiceOver's own chords native. if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false } if responder is NSControl, !(responder is NSTableView), !(responder is NSTextField), chord.modifiers.subtracting(.shift).isEmpty, Self.nativeNavigationKeys.contains(chord.key) { return false } if NSWorkspace.shared.isVoiceOverEnabled, chord.modifiers.contains([.control, .option]) { return false } return handleKey(chord, isRepeat: event.isARepeat) } func performMenuAction(_ action: PlayerAction) { // Native Command-Quit remains available while a text field owns focus. if action == .quit, NSApp.currentEvent?.modifierFlags.contains(.command) == true { perform(action) return } if let event = NSApp.currentEvent, event.type == .keyDown { if event.isARepeat && !action.allowsRepeat { return } guard let chord = KeyChord.from(event), keyBindings.shortcuts(for: action).contains(chord) else { // Return/Space activating a selected menu item is native navigation, not a player shortcut. perform(action) return } if let window = event.window ?? NSApp.keyWindow, !handleKeyEvent(event, in: window, chord: chord), let responder = window.firstResponder as? NSView { if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) } } return } perform(action) } } @MainActor func PlayerActionButton(model: PlayerModel, action: PlayerAction, title: String, selected: Bool = false) -> some View { // Context menus use the same explicit actions as the native application menus. Button { model.performMenuAction(action) } label: { Text(selected ? "✓ \(title)" : title) } .keyboardShortcut(model.keyBindings.shortcuts(for: action).first?.keyboardShortcut) .disabled(!model.canPerform(action)) } @MainActor struct PlayerQualityMenu: View { @ObservedObject var model: PlayerModel var body: some View { Menu("Quality") { PlayerActionButton(model: model, action: model.automaticQualityAction, title: model.automaticYouTubeQuality ? "Disable Automatic Quality" : "Enable Automatic Quality (up to 1080p)") Divider() PlayerActionButton(model: model, action: .qualityDefault, title: "Default (up to 1080p)", selected: !model.automaticYouTubeQuality && model.youtubeQuality == nil) ForEach(model.youtubeHeights, id: \.self) { height in PlayerActionButton(model: model, action: .quality(height), title: "\(height)p", selected: !model.automaticYouTubeQuality && model.youtubeQuality == height) } if !model.youtubeHeights.isEmpty { Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown") } } } } @MainActor struct PlayerCommands: Commands { @FocusedObject private var focusedModel: PlayerModel? var body: some Commands { // The player has one scene; app-level Open/Quit still work before it becomes focused. let model = focusedModel ?? PlayerModel.shared let hasFocusedScene = focusedModel != nil let playback = model.playbackAction let mute = model.muteAction let fullscreen = model.fullscreenAction let compact = model.compactAction let onTop = model.onTopAction let bufferInfo = model.bufferInfoAction let snapping = model.snappingAction let automaticQuality = model.automaticQualityAction let quitKeys = model.keyBindings.shortcuts(for: .quit) CommandGroup(replacing: .appTermination) { Button("Quit terebi") { model.performMenuAction(.quit) } .keyboardShortcut((quitKeys.first { $0.modifiers.contains(.command) } ?? quitKeys.first)?.keyboardShortcut) .disabled(!model.canPerform(.quit)) } CommandGroup(replacing: .newItem) { Button("Open File…") { model.performMenuAction(.openFile) } .keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut) .disabled(!model.canPerform(.openFile)) Button("Add to Queue…", action: model.addQueuePanel) .disabled(!model.canOpen) Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) } .keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut) .disabled(!model.canPerform(.openYoutube)) Button("Close File") { model.performMenuAction(.closeFile) } .keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.closeFile)) } CommandGroup(replacing: .saveItem) { Button("Close Window") { NSApp.keyWindow?.performClose(nil) } .disabled(focusedModel == nil) } CommandMenu("Playback") { CastDeviceMenu(model: model, icon: false) Divider() Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) } .keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(playback)) Button("Previous Queue Item") { model.advanceQueue(-1) } .disabled(!hasFocusedScene || !model.canPreviousQueueItem) Button("Next Queue Item") { model.advanceQueue(1) } .disabled(!hasFocusedScene || !model.canNextQueueItem) Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) } .keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s)) Button("Seek Forward 5 Seconds") { model.performMenuAction(.seekForward5s) } .keyboardShortcut(model.keyBindings.shortcuts(for: .seekForward5s).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.seekForward5s)) Button("Seek Back 10 Seconds") { model.performMenuAction(.seekBackward10s) } .keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward10s).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.seekBackward10s)) Button("Seek Forward 10 Seconds") { model.performMenuAction(.seekForward10s) } .keyboardShortcut(model.keyBindings.shortcuts(for: .seekForward10s).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.seekForward10s)) Divider() Button("Previous Frame") { model.performMenuAction(.stepFrameBackward) } .keyboardShortcut(model.keyBindings.shortcuts(for: .stepFrameBackward).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.stepFrameBackward)) Button("Next Frame") { model.performMenuAction(.stepFrameForward) } .keyboardShortcut(model.keyBindings.shortcuts(for: .stepFrameForward).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.stepFrameForward)) Divider() Button("Volume Up") { model.performMenuAction(.volumeUp) } .keyboardShortcut(model.keyBindings.shortcuts(for: .volumeUp).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.volumeUp)) Button("Volume Down") { model.performMenuAction(.volumeDown) } .keyboardShortcut(model.keyBindings.shortcuts(for: .volumeDown).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.volumeDown)) Button(model.volume == 0 ? "Unmute" : "Mute") { model.performMenuAction(mute) } .keyboardShortcut(model.keyBindings.shortcuts(for: mute).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(mute)) Divider() Menu("Quality") { Button(model.automaticYouTubeQuality ? "Disable Automatic Quality" : "Enable Automatic Quality (up to 1080p)") { model.performMenuAction(automaticQuality) } .keyboardShortcut(model.keyBindings.shortcuts(for: automaticQuality).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(automaticQuality)) Divider() Button(!model.automaticYouTubeQuality && model.youtubeQuality == nil ? "✓ Default (up to 1080p)" : "Default (up to 1080p)") { model.performMenuAction(.qualityDefault) } .keyboardShortcut(model.keyBindings.shortcuts(for: .qualityDefault).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.qualityDefault)) ForEach(model.youtubeHeights, id: \.self) { height in Button(!model.automaticYouTubeQuality && model.youtubeQuality == height ? "✓ \(height)p" : "\(height)p") { model.performMenuAction(.quality(height)) } .keyboardShortcut(model.keyBindings.shortcuts(for: .quality(height)).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(.quality(height))) } if !model.youtubeHeights.isEmpty { Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown") } } Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") { model.performMenuAction(bufferInfo) } .keyboardShortcut(model.keyBindings.shortcuts(for: bufferInfo).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(bufferInfo)) } CommandGroup(replacing: .sidebar) { Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() } .disabled(!hasFocusedScene || model.isCompact) Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") { model.performMenuAction(fullscreen) } .keyboardShortcut(model.keyBindings.shortcuts(for: fullscreen).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(fullscreen)) } CommandGroup(after: .windowArrangement) { Button(compact == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode") { model.performMenuAction(compact) } .keyboardShortcut(model.keyBindings.shortcuts(for: compact).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(compact)) Button(model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping") { model.performMenuAction(snapping) } .keyboardShortcut(model.keyBindings.shortcuts(for: snapping).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(snapping)) Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top") { model.performMenuAction(onTop) } .keyboardShortcut(model.keyBindings.shortcuts(for: onTop).first?.keyboardShortcut) .disabled(!hasFocusedScene || !model.canPerform(onTop)) } } }