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