feat: configure explicit state-dependent player keybindings
This commit is contained in:
@@ -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