feat: group playback settings under a TOML section

This commit is contained in:
2026-10-11 17:19:19 +02:00
parent b5eb4903db
commit a4c38bfa51
4 changed files with 50 additions and 15 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ def check_cli_instances(root, app, fixtures):
events=[{"key": "p", "code": 35}])
config.parent.mkdir(parents=True)
config_text = ('automatic_quality = true\n[ui]\n"buffer_info" = true # optional display\n'
config_text = ('[playback]\nautomatic_quality = true\n[ui]\n"buffer_info" = true # optional display\n'
'compact_snap_to_edges = false\n[keys]\nplay = ["cmd+8", "cmd+x", "x"]\n'
'pause = ["cmd+8", "cmd+x", "x"]\nshow_buffer_info = "b"\nhide_buffer_info = "b"\n')
config.write_text(config_text)
+28 -8
View File
@@ -129,7 +129,8 @@ struct ConfigurationChecks {
try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true)
let original = Data("""
# Root and nested keys can be quoted, with comments and whitespace.
# Sections and nested keys can be quoted, with comments and whitespace.
['playback']
'automatic_quality' = true
[ui]
"buffer_info" = true # session display default
@@ -151,7 +152,7 @@ struct ConfigurationChecks {
try require(try PlayerConfiguration.load(from: config) == configured, "a fresh load resets session overrides to file values")
let partial = root.appendingPathComponent("partial.toml")
try Data("automatic_quality = true\n".utf8).write(to: partial)
try Data("[playback]\nautomatic_quality = true\n".utf8).write(to: partial)
try require(try PlayerConfiguration.load(from: partial) == PlayerConfiguration(automaticQuality: true),
"partial configuration retains defaults for omitted settings")
let empty = root.appendingPathComponent("empty.toml")
@@ -175,7 +176,7 @@ struct ConfigurationChecks {
""".utf8).write(to: nested)
let nestedConfiguration = try PlayerConfiguration.load(from: nested)
try require(nestedConfiguration.showBufferInfo && nestedConfiguration.snapToEdges && !nestedConfiguration.automaticQuality,
"partial nested UI keeps omitted root and UI defaults")
"partial nested UI keeps omitted playback and UI defaults")
let expectedKeys = try KeyBindings(overrides: ["play": ["x"], "pause": ["x", "ctrl+x"], "mute": [],
"quality_1080p": ["alt+1"], "show_buffer_info": ["alt+b"],
"hide_buffer_info": ["alt+b"]])
@@ -185,21 +186,40 @@ struct ConfigurationChecks {
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
"key session overrides never persist")
let emptySections = root.appendingPathComponent("empty-sections.toml")
try Data("[ui]\n[keys]\n".utf8).write(to: emptySections)
try Data("[ui]\n[keys]\n[playback]\n".utf8).write(to: emptySections)
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
let reordered = root.appendingPathComponent("reordered.toml")
try Data("""
[keys]
play = "x"
pause = "x"
[ui]
buffer_info = true
compact_snap_to_edges = false
[playback]
automatic_quality = true
""".utf8).write(to: reordered)
try require(try PlayerConfiguration.load(from: reordered) ==
PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false,
keys: KeyBindings(overrides: ["play": ["x"], "pause": ["x"]])),
"playback after keys and UI retains every section's settings and bindings")
let invalid: [(String, String, [String])] = [
("syntax", "automatic_quality = [\n", ["automatic_quality"]),
("duplicate", "automatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
("unknown", "unknown_setting = true\n", ["unknown_setting"]),
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
("playback-scalar", "playback = true\n", ["playback"]),
("playback-unknown", "[playback]\nunknown_setting = true\n", ["playback.unknown_setting"]),
("playback-misplaced", "[ui]\nautomatic_quality = true\n", ["ui.automatic_quality"]),
("ui-unknown", "[ui]\nunknown_setting = true\n", ["ui.unknown_setting"]),
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
("string", "[ui]\nbuffer_info = \"true\"\n", []),
("integer", "automatic_quality = 1\n", []),
("integer", "[playback]\nautomatic_quality = 1\n", []),
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
("table", "[automatic_quality]\nenabled = true\n", ["automatic_quality"]),
("table", "[playback.automatic_quality]\nenabled = true\n", ["automatic_quality"]),
("ui-scalar", "ui = true\n", ["ui"]),
("keys-scalar", "keys = true\n", ["keys"]),
("key-bool", "[keys]\nplay = true\n", ["keys.play"]),
+5 -2
View File
@@ -65,6 +65,7 @@ Every fresh app startup automatically reads **`$XDG_CONFIG_HOME/terebi/config.to
The file is optional: a missing file means built-in defaults, and terebi never creates a file or directory on its own. The complete built-in configuration is:
```toml
[playback]
automatic_quality = false
[ui]
@@ -103,13 +104,15 @@ disable_automatic_quality = []
quality_default = []
```
All settings belong to `[playback]`, `[ui]`, or `[keys]`; these sections can appear in any order. Omitted sections use built-in defaults. An assignment belongs to its current TOML section, so place `automatic_quality` beneath `[playback]`, not at the root or under another header.
Omitted settings/actions retain their defaults. A shortcut string or string array **replaces** an action's default bindings; `[]` unbinds it. Keys are case-insensitive logical keys, not physical key positions. Use exact modifiers `cmd`, `ctrl`, `alt`/`option`, and `shift`, joined with `+`; uppercase `P` alone does not mean Shift+P. Named keys are `space`, `escape`, `return`, `tab`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown`, `plus`, and `f1`–`f35`; printable characters such as `,` and `.` also work. OS-reserved Cmd+Tab is rejected.
Bindings name explicit actions, not toggles. The same chord may be shared only by inverse pairs shown above: play/pause, mute/unmute, enter/exit fullscreen or compact, enable/disable always-on-top, show/hide buffer info, and enable/disable snapping or automatic quality. A press performs **one currently applicable action**, including pending playback/window intent; already-satisfied actions do nothing. Give each side different chords for dedicated enable/disable keys. Sharing a chord between unrelated actions, duplicate normalized chords, unknown actions, and malformed shortcuts are errors.
For YouTube, bind `quality_<height>p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. Captions are not implemented, so caption and `toggle_*` action names are rejected.
Behavior/UI values must be TOML booleans. Unknown keys, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. The old flat `show_buffer_info` and `snap_to_edges` settings are no longer accepted: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes.
Behavior/UI values must be TOML booleans. Unknown keys, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes.
Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history remains separate in UserDefaults; legacy UserDefaults settings are no longer read or written.
@@ -133,7 +136,7 @@ Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, co
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `[playback].automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency.
@@ -20,6 +20,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
var lines = [
"[playback]",
"automatic_quality = \(defaults.automaticQuality)",
"", "[ui]",
"buffer_info = \(defaults.showBufferInfo)",
@@ -74,7 +75,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
do {
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
automaticQuality: values.automaticQuality ?? false,
automaticQuality: values.playback?.automaticQuality ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true,
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch {
@@ -131,14 +132,16 @@ public struct PlayerConfiguration: Equatable, Sendable {
}
private struct FileConfiguration: Decodable {
let automaticQuality: Bool?
let playback: PlaybackConfiguration?
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"))
try validate(container, allowed: ["playback", "ui", "keys"])
if container.contains(SettingKey("playback")) {
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
} else { playback = nil }
if container.contains(SettingKey("ui")) {
ui = try UIConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("ui")))
} else { ui = nil }
@@ -148,6 +151,15 @@ public struct PlayerConfiguration: Equatable, Sendable {
}
}
private struct PlaybackConfiguration {
let automaticQuality: Bool?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["automatic_quality"])
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
}
}
private struct UIConfiguration {
let bufferInfo: Bool?
let compactSnapToEdges: Bool?