feat: configure explicit state-dependent player keybindings

This commit is contained in:
2026-10-11 15:25:24 +02:00
parent 722a225da5
commit 41e4a458cf
12 changed files with 1147 additions and 209 deletions
+275
View File
@@ -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))
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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() }
}
}
+135
View File
@@ -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 }
}
+78
View File
@@ -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
}
}
}
+111 -19
View File
@@ -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 {