389 lines
20 KiB
Swift
389 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 self.window === window, 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 = 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 PlayerOpeningEndingSkipToggle: View {
|
|
@ObservedObject var model: PlayerModel
|
|
|
|
var body: some View {
|
|
Toggle("Automatically Skip Openings and Endings", isOn: Binding(
|
|
get: { model.openingEndingAutoSkip },
|
|
set: { model.setOpeningEndingAutoSkip($0) }))
|
|
}
|
|
}
|
|
|
|
@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 {
|
|
@ObservedObject var model: PlayerModel
|
|
@FocusedObject private var focusedModel: PlayerModel?
|
|
|
|
var body: some Commands {
|
|
// Nonactivating compact panels need not provide SwiftUI's scene focus.
|
|
let hasFocusedScene = focusedModel != nil || model.window?.isKeyWindow == true
|
|
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") { model.window?.performClose(nil) }
|
|
.disabled(!hasFocusedScene)
|
|
}
|
|
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)
|
|
PlayerOpeningEndingSkipToggle(model: model)
|
|
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))
|
|
}
|
|
}
|
|
}
|