feat: configure explicit state-dependent player keybindings
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
!(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false }
|
||||
// Preserve focus traversal, control activation/navigation and VoiceOver's own chords.
|
||||
if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false }
|
||||
if window.firstResponder is NSControl,
|
||||
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) {
|
||||
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
|
||||
|
||||
CommandGroup(replacing: .appTermination) {
|
||||
Button("Quit terebi") { model.performMenuAction(.quit) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .quit).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("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") {
|
||||
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(playback))
|
||||
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(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ final class SystemPlaybackControls {
|
||||
switch action {
|
||||
case .play: accepted = model.setPlayback(playing: true)
|
||||
case .pause: accepted = model.setPlayback(playing: false)
|
||||
case .toggle: accepted = model.setPlayback(playing: model.state.status != PLAYER_PLAYING)
|
||||
case .toggle: accepted = model.setPlayback(playing: !model.effectivePlaying)
|
||||
case let .skip(seconds): accepted = model.seekRelative(seconds)
|
||||
}
|
||||
return accepted ? .success : .commandFailed
|
||||
|
||||
+201
-143
@@ -27,6 +27,24 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var isFullScreen = false
|
||||
@Published private(set) var isOnTop = false
|
||||
let compactWindow: CompactWindowController
|
||||
let keyBindings: KeyBindings
|
||||
@Published private(set) var playbackIntent: Bool?
|
||||
private var playbackInFlight: (playing: Bool, revision: UInt64)?
|
||||
@Published private(set) var fullScreenIntent: Bool?
|
||||
@Published private(set) var compactIntent: Bool?
|
||||
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
|
||||
var playbackAction: PlayerAction { effectivePlaying ? .pause : .play }
|
||||
var fullscreenAction: PlayerAction { (fullScreenIntent ?? isFullScreen) ? .exitFullscreen : .enterFullscreen }
|
||||
var compactAction: PlayerAction { (compactIntent ?? isCompact) ? .exitCompact : .enterCompact }
|
||||
var muteAction: PlayerAction { volume == 0 ? .unmute : .mute }
|
||||
var onTopAction: PlayerAction { isOnTop ? .disableAlwaysOnTop : .enableAlwaysOnTop }
|
||||
var bufferInfoAction: PlayerAction { showsBufferInfo ? .hideBufferInfo : .showBufferInfo }
|
||||
var snappingAction: PlayerAction {
|
||||
compactWindow.snappingEnabled ? .disableCompactSnapping : .enableCompactSnapping
|
||||
}
|
||||
var automaticQualityAction: PlayerAction {
|
||||
automaticYouTubeQuality ? .disableAutomaticQuality : .enableAutomaticQuality
|
||||
}
|
||||
var isCompact: Bool { compactWindow.isCompact }
|
||||
var canToggleCompact: Bool {
|
||||
isCompact || compactWindow.isTransitioning ||
|
||||
@@ -66,6 +84,7 @@ final class PlayerModel: ObservableObject {
|
||||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
keyBindings = configuration.keys
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
showsBufferInfo = configuration.showBufferInfo
|
||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
|
||||
@@ -80,7 +99,14 @@ final class PlayerModel: ObservableObject {
|
||||
videoView = nil
|
||||
commandError = "Could not create the media player."
|
||||
}
|
||||
compactWindow.onChange = { [weak self] in self?.objectWillChange.send() }
|
||||
compactWindow.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
if !self.compactWindow.isTransitioning {
|
||||
self.compactIntent = nil
|
||||
self.fullScreenIntent = nil
|
||||
}
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.poll() }
|
||||
}
|
||||
@@ -187,7 +213,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
let current = player_get_state(handle)
|
||||
let position = !isLiveStream && current.can_seek ? current.position : nil
|
||||
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
|
||||
resolveYouTube(url, height: height, restoring: (position, effectivePlaying))
|
||||
}
|
||||
|
||||
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
|
||||
@@ -200,6 +226,10 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
if !automatic {
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
}
|
||||
let request = UUID()
|
||||
youtubeRequest = request
|
||||
resolvingYouTube = true
|
||||
@@ -296,6 +326,8 @@ final class PlayerModel: ObservableObject {
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
|
||||
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
|
||||
guard let handle else { return }
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||
pendingRevision = revision
|
||||
fileName = title
|
||||
@@ -347,7 +379,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
setPlayback(playing: state.status != PLAYER_PLAYING)
|
||||
perform(playbackAction)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -355,7 +387,18 @@ final class PlayerModel: ObservableObject {
|
||||
guard let handle, canTogglePlayback else { return false }
|
||||
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if !playing { automaticQuality.resetHealth() }
|
||||
if playbackInFlight != nil {
|
||||
playbackIntent = playing
|
||||
revealControls()
|
||||
return true
|
||||
}
|
||||
guard effectivePlaying != playing else { return true }
|
||||
let revision = player_get_state(handle).revision
|
||||
let accepted = playing ? player_play(handle) : player_pause(handle)
|
||||
if accepted {
|
||||
playbackIntent = playing
|
||||
playbackInFlight = (playing, revision)
|
||||
}
|
||||
commandError = accepted ? nil : "Could not queue playback. Please try again."
|
||||
revealControls()
|
||||
return accepted
|
||||
@@ -382,6 +425,9 @@ final class PlayerModel: ObservableObject {
|
||||
commandError = "Could not queue closing the file. Please try again."
|
||||
return
|
||||
}
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
compactIntent = nil
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
@@ -420,12 +466,18 @@ final class PlayerModel: ObservableObject {
|
||||
return accepted
|
||||
}
|
||||
|
||||
func stepFrame(_ direction: Int32) {
|
||||
guard canStepFrame, let handle else { return }
|
||||
@discardableResult
|
||||
func stepFrame(_ direction: Int32) -> Bool {
|
||||
guard canStepFrame, let handle else { return false }
|
||||
let accepted = player_step_frame(handle, direction)
|
||||
if accepted { seekPending = false }
|
||||
if accepted {
|
||||
seekPending = false
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
}
|
||||
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
|
||||
revealControls()
|
||||
return accepted
|
||||
}
|
||||
|
||||
func setVolume(_ value: Double) {
|
||||
@@ -438,8 +490,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggleMute() {
|
||||
guard canSetVolume else { return }
|
||||
setVolume(volume == 0 ? lastAudibleVolume : 0)
|
||||
perform(muteAction)
|
||||
}
|
||||
|
||||
func attachWindow(_ window: NSWindow) {
|
||||
@@ -455,6 +506,7 @@ final class PlayerModel: ObservableObject {
|
||||
func updateWindowState() {
|
||||
guard let window else { return }
|
||||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||||
if !compactWindow.isTransitioning && fullScreenIntent == isFullScreen { fullScreenIntent = nil }
|
||||
windowActive = window.isKeyWindow
|
||||
if !compactWindow.isTransitioning {
|
||||
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
|
||||
@@ -464,25 +516,119 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggleFullScreen() {
|
||||
guard !compactWindow.isTransitioning else { return }
|
||||
if isCompact { compactWindow.restore(resumeFullScreen: false) }
|
||||
window?.toggleFullScreen(nil)
|
||||
revealControls()
|
||||
perform(fullscreenAction)
|
||||
}
|
||||
|
||||
func toggleCompact() {
|
||||
guard canToggleCompact else { return }
|
||||
compactWindow.toggle(aspect: state.video_aspect_ratio)
|
||||
controlsHovered = false
|
||||
titleHovered = false
|
||||
revealControls()
|
||||
perform(compactAction)
|
||||
}
|
||||
|
||||
func toggleOnTop() {
|
||||
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return }
|
||||
isOnTop.toggle()
|
||||
window?.level = isOnTop ? .floating : .normal
|
||||
revealControls()
|
||||
perform(onTopAction)
|
||||
}
|
||||
|
||||
func canPerform(_ action: PlayerAction) -> Bool {
|
||||
switch action {
|
||||
case .play: return canTogglePlayback && !effectivePlaying
|
||||
case .pause: return canTogglePlayback && effectivePlaying
|
||||
case .mute: return canSetVolume && volume > 0
|
||||
case .unmute: return canSetVolume && volume == 0
|
||||
case .volumeUp: return canSetVolume && volume < 1
|
||||
case .volumeDown: return canSetVolume && volume > 0
|
||||
case .seekForward5s, .seekBackward5s, .seekForward10s, .seekBackward10s: return canSeek
|
||||
case .stepFrameForward, .stepFrameBackward: return canStepFrame
|
||||
case .enterFullscreen, .exitFullscreen:
|
||||
return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
|
||||
(action == .enterFullscreen ? !isFullScreen : isFullScreen)
|
||||
case .enterCompact:
|
||||
return window != nil && !(compactIntent ?? isCompact) && !compactWindow.isTransitioning &&
|
||||
canTogglePlayback && state.has_video &&
|
||||
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0
|
||||
case .exitCompact: return window != nil && (compactIntent ?? isCompact)
|
||||
case .enableAlwaysOnTop, .disableAlwaysOnTop:
|
||||
return window != nil && !isFullScreen && fullScreenIntent == nil &&
|
||||
!isCompact && compactIntent != true && !compactWindow.isTransitioning &&
|
||||
(action == .enableAlwaysOnTop ? !isOnTop : isOnTop)
|
||||
case .openFile, .openYoutube: return canOpen
|
||||
case .closeFile: return canClose
|
||||
case .quit: return true
|
||||
case .showBufferInfo: return !showsBufferInfo
|
||||
case .hideBufferInfo: return showsBufferInfo
|
||||
case .enableCompactSnapping: return !compactWindow.snappingEnabled
|
||||
case .disableCompactSnapping: return compactWindow.snappingEnabled
|
||||
case .enableAutomaticQuality: return !automaticYouTubeQuality
|
||||
case .disableAutomaticQuality: return automaticYouTubeQuality
|
||||
case .qualityDefault:
|
||||
return canChangeYouTubeQuality && (automaticYouTubeQuality || youtubeQuality != nil)
|
||||
case let .quality(height):
|
||||
return canChangeYouTubeQuality && youtubeHeights.contains(height) &&
|
||||
(automaticYouTubeQuality || youtubeQuality != height)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func perform(_ action: PlayerAction) -> Bool {
|
||||
guard canPerform(action) else { return false }
|
||||
switch action {
|
||||
case .play: return setPlayback(playing: true)
|
||||
case .pause: return setPlayback(playing: false)
|
||||
case .mute: setVolume(0)
|
||||
case .unmute: setVolume(lastAudibleVolume)
|
||||
case .volumeUp: setVolume(min(1, volume + 0.05))
|
||||
case .volumeDown: setVolume(max(0, volume - 0.05))
|
||||
case .seekForward5s: return seekRelative(5)
|
||||
case .seekBackward5s: return seekRelative(-5)
|
||||
case .seekForward10s: return seekRelative(10)
|
||||
case .seekBackward10s: return seekRelative(-10)
|
||||
case .stepFrameForward: return stepFrame(1)
|
||||
case .stepFrameBackward: return stepFrame(-1)
|
||||
case .enterFullscreen, .exitFullscreen:
|
||||
if isCompact {
|
||||
compactIntent = false
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
}
|
||||
fullScreenIntent = action == .enterFullscreen
|
||||
window?.toggleFullScreen(nil)
|
||||
revealControls()
|
||||
case .enterCompact:
|
||||
compactIntent = true
|
||||
compactWindow.toggle(aspect: state.video_aspect_ratio)
|
||||
controlsHovered = false
|
||||
titleHovered = false
|
||||
revealControls()
|
||||
case .exitCompact:
|
||||
compactIntent = false
|
||||
compactWindow.restore()
|
||||
revealControls()
|
||||
case .enableAlwaysOnTop, .disableAlwaysOnTop:
|
||||
isOnTop = action == .enableAlwaysOnTop
|
||||
window?.level = isOnTop ? .floating : .normal
|
||||
revealControls()
|
||||
case .openFile: openPanel()
|
||||
case .openYoutube: openYouTubePanel()
|
||||
case .closeFile: closeFile()
|
||||
case .quit: NSApp.terminate(nil)
|
||||
case .showBufferInfo: setShowsBufferInfo(true)
|
||||
case .hideBufferInfo: setShowsBufferInfo(false)
|
||||
case .enableCompactSnapping: compactWindow.snappingEnabled = true
|
||||
case .disableCompactSnapping: compactWindow.snappingEnabled = false
|
||||
case .enableAutomaticQuality: setAutomaticYouTubeQuality(true)
|
||||
case .disableAutomaticQuality: setAutomaticYouTubeQuality(false)
|
||||
case .qualityDefault: setYouTubeQuality(nil)
|
||||
case let .quality(height): setYouTubeQuality(height)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleKey(_ chord: KeyChord, isRepeat: Bool = false) -> Bool {
|
||||
let actions = keyBindings.actions(for: chord)
|
||||
guard !actions.isEmpty else { return false }
|
||||
for action in actions where canPerform(action) {
|
||||
if !isRepeat || action.allowsRepeat { perform(action) }
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setControlsHovered(_ hovered: Bool) {
|
||||
@@ -594,6 +740,17 @@ final class PlayerModel: ObservableObject {
|
||||
defer { systemPlayback?.update() }
|
||||
let statusChanged = state.status != next.status
|
||||
state = next
|
||||
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR {
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
} else if let flight = playbackInFlight, next.revision != flight.revision,
|
||||
next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == flight.playing,
|
||||
next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED {
|
||||
let desired = playbackIntent
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
if let desired, desired != flight.playing { setPlayback(playing: desired) }
|
||||
}
|
||||
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
|
||||
(!isBusy && !next.has_video) {
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
@@ -633,7 +790,7 @@ final class PlayerModel: ObservableObject {
|
||||
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
|
||||
let sample = AutomaticQuality.Sample(
|
||||
enabled: automaticYouTubeQuality, network: isNetworkMedia,
|
||||
playing: state.status == PLAYER_PLAYING, opening: isBusy || resolvingYouTube || resumePosition != nil,
|
||||
playing: effectivePlaying, opening: isBusy || resolvingYouTube || resumePosition != nil,
|
||||
seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream,
|
||||
buffering: state.buffering, position: state.position, duration: state.duration,
|
||||
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
|
||||
@@ -906,52 +1063,22 @@ private struct PlayerContent: View {
|
||||
|
||||
private var actionsMenu: some View {
|
||||
Menu {
|
||||
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
|
||||
Button("Close File", action: model.closeFile).disabled(!model.canClose)
|
||||
if !model.youtubeHeights.isEmpty {
|
||||
Divider()
|
||||
Menu("Quality") {
|
||||
Toggle("Automatic Quality (up to 1080p)", isOn: Binding(
|
||||
get: { model.automaticYouTubeQuality },
|
||||
set: { model.setAutomaticYouTubeQuality($0) }))
|
||||
Divider()
|
||||
Button { model.setYouTubeQuality(nil) } label: {
|
||||
HStack {
|
||||
Text("Default (up to 1080p)")
|
||||
if !model.automaticYouTubeQuality && model.youtubeQuality == nil {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeYouTubeQuality)
|
||||
ForEach(model.youtubeHeights, id: \.self) { height in
|
||||
Button { model.setYouTubeQuality(height) } label: {
|
||||
HStack {
|
||||
Text("\(height)p")
|
||||
if !model.automaticYouTubeQuality && model.youtubeQuality == height {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeYouTubeQuality)
|
||||
}
|
||||
}
|
||||
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
||||
}
|
||||
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
|
||||
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
|
||||
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
|
||||
Divider()
|
||||
Toggle("Show Buffer Info", isOn: Binding(
|
||||
get: { model.showsBufferInfo },
|
||||
set: { model.setShowsBufferInfo($0) }))
|
||||
PlayerQualityMenu(model: model)
|
||||
Divider()
|
||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||
.disabled(!model.canToggleCompact)
|
||||
Toggle("Snap to Screen Edges", isOn: Binding(
|
||||
get: { model.compactWindow.snappingEnabled },
|
||||
set: { model.compactWindow.snappingEnabled = $0 }))
|
||||
PlayerActionButton(model: model, action: model.bufferInfoAction,
|
||||
title: model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info")
|
||||
Divider()
|
||||
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
|
||||
PlayerActionButton(model: model, action: model.compactAction,
|
||||
title: model.compactAction == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode")
|
||||
PlayerActionButton(model: model, action: model.snappingAction,
|
||||
title: model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping")
|
||||
Divider()
|
||||
PlayerActionButton(model: model, action: model.onTopAction,
|
||||
title: model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top")
|
||||
} label: {
|
||||
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
|
||||
}
|
||||
@@ -965,8 +1092,8 @@ private struct PlayerContent: View {
|
||||
private var compactControls: some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 16) {
|
||||
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
|
||||
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
||||
iconButton(model.effectivePlaying ? "pause.fill" : "play.fill",
|
||||
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
|
||||
.disabled(!model.canTogglePlayback)
|
||||
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
||||
.disabled(model.compactWindow.isTransitioning)
|
||||
@@ -1005,13 +1132,13 @@ private struct PlayerContent: View {
|
||||
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||||
.disabled(!model.canSeek)
|
||||
Button(action: model.togglePlayback) {
|
||||
Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill")
|
||||
Image(systemName: model.effectivePlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 24))
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play")
|
||||
.help(model.state.status == PLAYER_PLAYING ? "Pause (K / Space)" : "Play (K / Space)")
|
||||
.accessibilityLabel(model.effectivePlaying ? "Pause" : "Play")
|
||||
.help(model.effectivePlaying ? "Pause" : "Play")
|
||||
.disabled(!model.canTogglePlayback)
|
||||
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||||
.disabled(!model.canSeek)
|
||||
@@ -1231,34 +1358,8 @@ private struct WindowChrome: NSViewRepresentable {
|
||||
required init?(coder: NSCoder) { nil }
|
||||
|
||||
func handleKeyEvent(_ event: NSEvent) -> Bool {
|
||||
guard event.type == .keyDown, let window = configuredWindow, event.window === window,
|
||||
!(window.firstResponder is NSTextView),
|
||||
event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return false }
|
||||
if event.keyCode == 53 && model.isFullScreen {
|
||||
if !event.isARepeat { model.toggleFullScreen() }
|
||||
return true
|
||||
}
|
||||
let key = event.charactersIgnoringModifiers?.lowercased()
|
||||
switch key {
|
||||
case "j": model.seekRelative(-10)
|
||||
case "l": model.seekRelative(10)
|
||||
case ",": model.stepFrame(-1)
|
||||
case ".": model.stepFrame(1)
|
||||
case "k", " ":
|
||||
if !event.isARepeat { model.togglePlayback() }
|
||||
case "m":
|
||||
if !event.isARepeat { model.toggleMute() }
|
||||
case "f":
|
||||
if !event.isARepeat { model.toggleFullScreen() }
|
||||
case "p":
|
||||
if !event.isARepeat { model.toggleOnTop() }
|
||||
case "o":
|
||||
if !event.isARepeat { model.openPanel() }
|
||||
case "q":
|
||||
if !event.isARepeat { NSApp.terminate(nil) }
|
||||
default: return false
|
||||
}
|
||||
return true
|
||||
guard let window = configuredWindow else { return false }
|
||||
return model.handleKeyEvent(event, in: window)
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
@@ -1396,54 +1497,11 @@ struct TerebiApp: App {
|
||||
var body: some Scene {
|
||||
Window("\(model.fileName ?? "terebi")", id: "terebi") {
|
||||
PlayerContent(model: model)
|
||||
.focusedSceneObject(model)
|
||||
}
|
||||
.defaultSize(width: 900, height: 560)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("Open File…", action: model.openPanel).keyboardShortcut("o")
|
||||
.disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel)
|
||||
.keyboardShortcut("o", modifiers: [.command, .shift])
|
||||
.disabled(!model.canOpen)
|
||||
Button("Close File", action: model.closeFile)
|
||||
.keyboardShortcut("w", modifiers: [.command, .shift])
|
||||
.disabled(!model.canClose)
|
||||
}
|
||||
CommandMenu("Playback") {
|
||||
Button(model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
||||
.disabled(!model.canTogglePlayback)
|
||||
Button("Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||||
.keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek)
|
||||
Button("Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||||
.keyboardShortcut(.rightArrow, modifiers: []).disabled(!model.canSeek)
|
||||
Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek)
|
||||
Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek)
|
||||
Divider()
|
||||
Button("Previous Frame") { model.stepFrame(-1) }.disabled(!model.canStepFrame)
|
||||
Button("Next Frame") { model.stepFrame(1) }.disabled(!model.canStepFrame)
|
||||
Divider()
|
||||
Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) }
|
||||
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
|
||||
Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) }
|
||||
.keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume)
|
||||
Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
|
||||
.disabled(!model.canSetVolume)
|
||||
}
|
||||
CommandGroup(after: .windowArrangement) {
|
||||
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
||||
.keyboardShortcut("f", modifiers: [.control, .command])
|
||||
.disabled(model.compactWindow.isTransitioning)
|
||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||
.keyboardShortcut("p", modifiers: [.control, .command])
|
||||
.disabled(!model.canToggleCompact)
|
||||
Toggle("Snap to Screen Edges", isOn: Binding(
|
||||
get: { model.compactWindow.snappingEnabled },
|
||||
set: { model.compactWindow.snappingEnabled = $0 }))
|
||||
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
|
||||
}
|
||||
}
|
||||
.commands { PlayerCommands() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
public struct KeyModifiers: OptionSet, Hashable, Sendable {
|
||||
public let rawValue: UInt8
|
||||
public init(rawValue: UInt8) { self.rawValue = rawValue }
|
||||
public static let command = Self(rawValue: 1 << 0)
|
||||
public static let control = Self(rawValue: 1 << 1)
|
||||
public static let option = Self(rawValue: 1 << 2)
|
||||
public static let shift = Self(rawValue: 1 << 3)
|
||||
}
|
||||
|
||||
public struct KeyChord: Hashable, Sendable {
|
||||
public let key: String
|
||||
public let modifiers: KeyModifiers
|
||||
|
||||
public init(key: String, modifiers: KeyModifiers) {
|
||||
self.key = key
|
||||
self.modifiers = modifiers
|
||||
}
|
||||
|
||||
public init(_ text: String) throws {
|
||||
let parts = text.lowercased().split(separator: "+", omittingEmptySubsequences: false)
|
||||
func invalid(_ reason: String) -> BindingError {
|
||||
BindingError(message: "Invalid chord '\(text)': \(reason)")
|
||||
}
|
||||
guard let last = parts.last, !last.isEmpty, parts.allSatisfy({ !$0.isEmpty }) else {
|
||||
throw invalid("empty key or modifier; use 'plus' for the + key.")
|
||||
}
|
||||
var modifiers: KeyModifiers = []
|
||||
for part in parts.dropLast() {
|
||||
let modifier: KeyModifiers
|
||||
switch part {
|
||||
case "cmd": modifier = .command
|
||||
case "ctrl": modifier = .control
|
||||
case "alt", "option": modifier = .option
|
||||
case "shift": modifier = .shift
|
||||
default: throw invalid("unknown modifier '\(part)'.")
|
||||
}
|
||||
guard !modifiers.contains(modifier) else { throw invalid("duplicate modifier '\(part)'.") }
|
||||
modifiers.insert(modifier)
|
||||
}
|
||||
let key = String(last)
|
||||
let namedKeys: Set<String> = ["space", "escape", "return", "tab", "backspace", "delete",
|
||||
"left", "right", "up", "down", "home", "end", "pageup", "pagedown", "plus"]
|
||||
let functionKey: Bool
|
||||
if key.hasPrefix("f"), let number = Int(key.dropFirst()) {
|
||||
functionKey = (1...35).contains(number) && key == "f\(number)"
|
||||
} else {
|
||||
functionKey = false
|
||||
}
|
||||
let printable = key.count == 1 && key != "+" && key.unicodeScalars.allSatisfy {
|
||||
!CharacterSet.controlCharacters.contains($0) && !CharacterSet.whitespacesAndNewlines.contains($0)
|
||||
}
|
||||
guard namedKeys.contains(key) || functionKey || printable else { throw invalid("unknown key '\(key)'.") }
|
||||
guard !(key == "tab" && modifiers.contains(.command)) else {
|
||||
throw invalid("unsupported OS-reserved cmd+tab shortcut.")
|
||||
}
|
||||
self.init(key: key == "plus" ? "+" : key, modifiers: modifiers)
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
var parts: [String] = []
|
||||
if modifiers.contains(.control) { parts.append("ctrl") }
|
||||
if modifiers.contains(.option) { parts.append("alt") }
|
||||
if modifiers.contains(.shift) { parts.append("shift") }
|
||||
if modifiers.contains(.command) { parts.append("cmd") }
|
||||
parts.append(key == "+" ? "plus" : key)
|
||||
return parts.joined(separator: "+")
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyBindings: Equatable, Sendable {
|
||||
private let byAction: [PlayerAction: [KeyChord]]
|
||||
private let byChord: [KeyChord: [PlayerAction]]
|
||||
|
||||
private static let builtIn: [PlayerAction: [KeyChord]] = {
|
||||
let texts: [PlayerAction: [String]] = [
|
||||
.play: ["k", "space"], .pause: ["k", "space"],
|
||||
.mute: ["m"], .unmute: ["m"], .volumeUp: ["up"], .volumeDown: ["down"],
|
||||
.seekForward5s: ["right"], .seekBackward5s: ["left"],
|
||||
.seekForward10s: ["l"], .seekBackward10s: ["j"],
|
||||
.stepFrameForward: ["."], .stepFrameBackward: [","],
|
||||
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
|
||||
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
|
||||
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
|
||||
.openFile: ["o", "cmd+o"], .openYoutube: ["cmd+shift+o"],
|
||||
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
|
||||
]
|
||||
return texts.mapValues { $0.map { try! KeyChord($0) } }
|
||||
}()
|
||||
private static let defaults = try! KeyBindings(overrides: [:])
|
||||
|
||||
public init() { self = Self.defaults }
|
||||
|
||||
public init(overrides: [String: [String]]) throws {
|
||||
var bindings = Self.builtIn
|
||||
for name in overrides.keys.sorted() {
|
||||
guard let action = PlayerAction(name: name) else {
|
||||
throw BindingError(message: "Unknown action 'keys.\(name)'.")
|
||||
}
|
||||
var chords: [KeyChord] = []
|
||||
var seen: Set<KeyChord> = []
|
||||
for text in overrides[name]! {
|
||||
let chord: KeyChord
|
||||
do { chord = try KeyChord(text) }
|
||||
catch { throw BindingError(message: "keys.\(name): \(error.localizedDescription)") }
|
||||
guard seen.insert(chord).inserted else {
|
||||
throw BindingError(message: "keys.\(name): duplicate chord '\(text)' (\(chord.description)).")
|
||||
}
|
||||
chords.append(chord)
|
||||
}
|
||||
if chords.isEmpty { bindings.removeValue(forKey: action) }
|
||||
else { bindings[action] = chords }
|
||||
}
|
||||
var lookup: [KeyChord: [PlayerAction]] = [:]
|
||||
for action in bindings.keys.sorted(by: { $0.name < $1.name }) {
|
||||
for chord in bindings[action]! {
|
||||
if let existing = lookup[chord]?.first, existing.inverse != action {
|
||||
throw BindingError(message: "Chord '\(chord.description)' conflicts between keys.\(existing.name) and keys.\(action.name).")
|
||||
}
|
||||
lookup[chord, default: []].append(action)
|
||||
}
|
||||
}
|
||||
byAction = bindings
|
||||
byChord = lookup
|
||||
}
|
||||
|
||||
public func shortcuts(for action: PlayerAction) -> [KeyChord] { byAction[action] ?? [] }
|
||||
public func actions(for chord: KeyChord) -> [PlayerAction] { byChord[chord] ?? [] }
|
||||
}
|
||||
|
||||
struct BindingError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
public enum PlayerAction: Hashable, Sendable {
|
||||
case play, pause, mute, unmute, volumeUp, volumeDown
|
||||
case seekForward5s, seekBackward5s, seekForward10s, seekBackward10s
|
||||
case stepFrameForward, stepFrameBackward
|
||||
case enterFullscreen, exitFullscreen, enterCompact, exitCompact
|
||||
case enableAlwaysOnTop, disableAlwaysOnTop
|
||||
case openFile, openYoutube, closeFile, quit
|
||||
case showBufferInfo, hideBufferInfo
|
||||
case enableCompactSnapping, disableCompactSnapping
|
||||
case enableAutomaticQuality, disableAutomaticQuality
|
||||
case qualityDefault, quality(Int)
|
||||
|
||||
static let namedActions: [(String, PlayerAction)] = [
|
||||
("play", .play), ("pause", .pause), ("mute", .mute), ("unmute", .unmute),
|
||||
("volume_up", .volumeUp), ("volume_down", .volumeDown),
|
||||
("seek_forward_5s", .seekForward5s), ("seek_backward_5s", .seekBackward5s),
|
||||
("seek_forward_10s", .seekForward10s), ("seek_backward_10s", .seekBackward10s),
|
||||
("step_frame_forward", .stepFrameForward), ("step_frame_backward", .stepFrameBackward),
|
||||
("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen),
|
||||
("enter_compact", .enterCompact), ("exit_compact", .exitCompact),
|
||||
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
|
||||
("open_file", .openFile), ("open_youtube", .openYoutube), ("close_file", .closeFile), ("quit", .quit),
|
||||
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
|
||||
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
|
||||
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
|
||||
("quality_default", .qualityDefault)
|
||||
]
|
||||
private static let actionsByName = Dictionary(uniqueKeysWithValues: namedActions)
|
||||
private static let namesByAction = Dictionary(uniqueKeysWithValues: namedActions.map { ($0.1, $0.0) })
|
||||
|
||||
public init?(name: String) {
|
||||
if let action = Self.actionsByName[name] {
|
||||
self = action
|
||||
} else if name.hasPrefix("quality_"), name.hasSuffix("p") {
|
||||
let digits = name.dropFirst("quality_".count).dropLast()
|
||||
guard !digits.isEmpty, digits.allSatisfy({ $0 >= "0" && $0 <= "9" }),
|
||||
let height = Int(digits), height > 0, String(height) == digits else { return nil }
|
||||
self = .quality(height)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
if case .quality(let height) = self { return "quality_\(height)p" }
|
||||
return Self.namesByAction[self]!
|
||||
}
|
||||
|
||||
public var inverse: PlayerAction? {
|
||||
switch self {
|
||||
case .play: return .pause
|
||||
case .pause: return .play
|
||||
case .mute: return .unmute
|
||||
case .unmute: return .mute
|
||||
case .enterFullscreen: return .exitFullscreen
|
||||
case .exitFullscreen: return .enterFullscreen
|
||||
case .enterCompact: return .exitCompact
|
||||
case .exitCompact: return .enterCompact
|
||||
case .enableAlwaysOnTop: return .disableAlwaysOnTop
|
||||
case .disableAlwaysOnTop: return .enableAlwaysOnTop
|
||||
case .showBufferInfo: return .hideBufferInfo
|
||||
case .hideBufferInfo: return .showBufferInfo
|
||||
case .enableCompactSnapping: return .disableCompactSnapping
|
||||
case .disableCompactSnapping: return .enableCompactSnapping
|
||||
case .enableAutomaticQuality: return .disableAutomaticQuality
|
||||
case .disableAutomaticQuality: return .enableAutomaticQuality
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var allowsRepeat: Bool {
|
||||
switch self {
|
||||
case .volumeUp, .volumeDown, .seekForward5s, .seekBackward5s,
|
||||
.seekForward10s, .seekBackward10s, .stepFrameForward, .stepFrameBackward: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var showBufferInfo: Bool
|
||||
public var automaticQuality: Bool
|
||||
public var snapToEdges: Bool
|
||||
public var keys: KeyBindings
|
||||
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true) {
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
||||
keys: KeyBindings = KeyBindings()) {
|
||||
self.showBufferInfo = showBufferInfo
|
||||
self.automaticQuality = automaticQuality
|
||||
self.snapToEdges = snapToEdges
|
||||
self.keys = keys
|
||||
}
|
||||
|
||||
public static func fileURL(environment: [String: String], homeDirectory: URL) -> URL {
|
||||
@@ -49,34 +52,123 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
throw ConfigurationError(message: "\(url.path): \(error.localizedDescription)")
|
||||
}
|
||||
do {
|
||||
let values = try TOMLDecoder(isLenient: false).decode([String: Bool].self, from: data)
|
||||
for key in values.keys {
|
||||
switch key {
|
||||
case "show_buffer_info", "automatic_quality", "snap_to_edges": break
|
||||
default: throw ConfigurationError(message: "Unknown setting '\(key)'.")
|
||||
}
|
||||
}
|
||||
var configuration = Self()
|
||||
configuration.showBufferInfo = values["show_buffer_info"] ?? configuration.showBufferInfo
|
||||
configuration.automaticQuality = values["automatic_quality"] ?? configuration.automaticQuality
|
||||
configuration.snapToEdges = values["snap_to_edges"] ?? configuration.snapToEdges
|
||||
return configuration
|
||||
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
|
||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||
automaticQuality: values.automaticQuality ?? false,
|
||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
|
||||
} catch {
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error))")
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
|
||||
}
|
||||
}
|
||||
|
||||
private static func diagnostic(for error: Error) -> String {
|
||||
private static func diagnostic(for error: Error, data: Data) -> String {
|
||||
let context: DecodingError.Context
|
||||
var missingKey: String?
|
||||
switch error {
|
||||
case DecodingError.dataCorrupted(let value), DecodingError.typeMismatch(_, let value),
|
||||
DecodingError.valueNotFound(_, let value), DecodingError.keyNotFound(_, let value): context = value
|
||||
DecodingError.valueNotFound(_, let value): context = value
|
||||
case DecodingError.keyNotFound(let key, let value):
|
||||
context = value
|
||||
missingKey = key.stringValue
|
||||
case let error as ConfigurationError: return error.message
|
||||
case let error as BindingError: return error.message
|
||||
default: return String(describing: error)
|
||||
}
|
||||
if let underlying = context.underlyingError { return String(describing: underlying) }
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return path.isEmpty ? context.debugDescription : "\(path): \(context.debugDescription)"
|
||||
var path = context.codingPath.map(\.stringValue)
|
||||
if let missingKey, path.last != missingKey { path.append(missingKey) }
|
||||
let detail = context.underlyingError.map { String(describing: $0) } ?? context.debugDescription
|
||||
if !path.isEmpty { return "\(path.joined(separator: ".")): \(detail)" }
|
||||
// TOML syntax failures supply a line number, but not always the setting.
|
||||
if let text = String(data: data, encoding: .utf8),
|
||||
let marker = detail.range(of: "(Line "),
|
||||
let end = detail[marker.upperBound...].firstIndex(of: ")"),
|
||||
let line = Int(detail[marker.upperBound..<end]), line > 0 {
|
||||
let lines = text.components(separatedBy: "\n")
|
||||
let index = min(line - 1, lines.count - 1)
|
||||
let source = lines[...index].last(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
|
||||
if let source { return "\(detail) [\(source)]" }
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
// An enum CodingKey would silently hide unknown TOML keys from allKeys.
|
||||
private struct SettingKey: CodingKey {
|
||||
let stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
init(_ value: String) { stringValue = value }
|
||||
init?(stringValue: String) { self.init(stringValue) }
|
||||
init?(intValue: Int) { return nil }
|
||||
}
|
||||
|
||||
private static func validate(_ container: KeyedDecodingContainer<SettingKey>, allowed: Set<String>) throws {
|
||||
for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) {
|
||||
guard allowed.contains(key.stringValue) else {
|
||||
let path = (container.codingPath + [key]).map(\.stringValue).joined(separator: ".")
|
||||
throw ConfigurationError(message: "Unknown setting '\(path)'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FileConfiguration: Decodable {
|
||||
let automaticQuality: Bool?
|
||||
let ui: UIConfiguration?
|
||||
let keys: KeysConfiguration?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: SettingKey.self)
|
||||
try validate(container, allowed: ["automatic_quality", "ui", "keys"])
|
||||
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
||||
if container.contains(SettingKey("ui")) {
|
||||
ui = try UIConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("ui")))
|
||||
} else { ui = nil }
|
||||
if container.contains(SettingKey("keys")) {
|
||||
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
|
||||
} else { keys = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private struct UIConfiguration {
|
||||
let bufferInfo: Bool?
|
||||
let compactSnapToEdges: Bool?
|
||||
|
||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges"])
|
||||
bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info"))
|
||||
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeysConfiguration {
|
||||
let overrides: [String: [String]]
|
||||
|
||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||
var values: [String: [String]] = [:]
|
||||
for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) {
|
||||
guard PlayerAction(name: key.stringValue) != nil else {
|
||||
throw ConfigurationError(message: "Unknown action 'keys.\(key.stringValue)'.")
|
||||
}
|
||||
values[key.stringValue] = try container.decode(ShortcutList.self, forKey: key).chords
|
||||
}
|
||||
overrides = values
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShortcutList: Decodable {
|
||||
let chords: [String]
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
if let chord = try? String(from: decoder) {
|
||||
chords = [chord]
|
||||
} else {
|
||||
do { chords = try [String](from: decoder) }
|
||||
catch {
|
||||
throw DecodingError.typeMismatch([String].self,
|
||||
DecodingError.Context(codingPath: decoder.codingPath,
|
||||
debugDescription: "Expected a shortcut string or an array of shortcut strings."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConfigurationError: LocalizedError {
|
||||
|
||||
Reference in New Issue
Block a user