Files
terebi/Sources/App/PlayerActions.swift
T

376 lines
20 KiB
Swift

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<String> = [
"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: .openStream).contains(chord) {
return perform(.openStream)
}
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 PlayerMiruroMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
if !model.miruroVideoProviders.isEmpty {
Menu("Miruro") {
Menu("Video Host") {
Button(model.miruroVideoProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroVideoProvider("")
}
ForEach(model.miruroVideoProviders, id: \.self) { provider in
Button(model.miruroVideoProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroVideoProvider(provider)
}
}
Text("Current: \(model.selectedMiruroVideoProvider ?? "None")")
}
Menu("Caption Provider") {
Button(model.miruroCaptionProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroCaptionProvider("")
}
ForEach(model.miruroCaptionProviders, id: \.self) { provider in
Button(model.miruroCaptionProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroCaptionProvider(provider)
}
}
Text("Current: \(model.selectedMiruroCaptionProvider ?? "None")")
}
}
.disabled(!model.canChangeMiruroProvider)
}
}
}
@MainActor
struct PlayerSponsorBlockMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
Menu("SponsorBlock") {
Toggle("Show Sponsored Ranges", isOn: Binding(
get: { model.sponsorBlockEnabled },
set: { model.setSponsorBlockEnabled($0) }))
Toggle("Automatically Skip Sponsors", isOn: Binding(
get: { model.sponsorBlockAutoSkip },
set: { model.setSponsorBlockAutoSkip($0) }))
.disabled(!model.sponsorBlockEnabled)
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
Text("Reduces disclosure; not anonymous.")
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
Menu("Acknowledgements") {
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
Link("CC BY-NC-SA 4.0 License",
destination: URL(string: "https://creativecommons.org/licenses/by-nc-sa/4.0/")!)
Text("Display filters and merges ranges; does not modify source timings.")
}
}
}
}
@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 Stream…") { model.performMenuAction(.openStream) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openStream).first?.keyboardShortcut)
.disabled(!model.canPerform(.openStream))
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")
}
}
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
PlayerSponsorBlockMenu(model: model)
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.queueVisible ? "Hide Queue" : "Show Queue") { model.performMenuAction(model.queueAction) }
.keyboardShortcut(model.keyBindings.shortcuts(for: model.queueAction).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(model.queueAction))
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))
}
}
}