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
+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 {