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
+58 -2
View File
@@ -48,6 +48,17 @@ def check_cli_instances(root, app, fixtures):
def settings(state): def settings(state):
return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"]) return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"])
def input_request(pid, count, ready, message, events=None, media=None):
request = {"id": uuid.uuid4().hex, "events": events or []}
if media is not None:
request["media"] = str(media)
path = root / f"keys-{pid}.json"
pending = path.with_suffix(".tmp")
pending.write_text(json.dumps(request))
pending.replace(path)
return wait_states(count, lambda states: states[pid]["requestID"] == request["id"] and ready(states[pid]),
message)
with StreamingServer(fixtures) as server: with StreamingServer(fixtures) as server:
if extractor and runtime: if extractor and runtime:
metadata = root / "youtube.json" metadata = root / "youtube.json"
@@ -80,9 +91,25 @@ def check_cli_instances(root, app, fixtures):
invoke([]) invoke([])
wait_states(1, lambda states: states[first_pid]["media"] == movie.name, wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
"warm default CLI reuses the existing process without replacing media") "warm default CLI reuses the existing process without replacing media")
input_request(first_pid, 1, lambda state: state["status"] == 2, "default Space pauses once",
events=[{"key": " ", "code": 49}])
input_request(first_pid, 1, lambda state: state["status"] == 2, "held Space cannot oscillate",
events=[{"key": " ", "code": 49, "repeat": True}])
input_request(first_pid, 1, lambda state: state["status"] == 3, "same Space resumes",
events=[{"key": " ", "code": 49}])
input_request(first_pid, 1, lambda state: state["status"] == 3, "rapid pause/play preserves latest intent",
events=[{"key": " ", "code": 49}, {"key": " ", "code": 49}])
input_request(first_pid, 1, lambda state: state["compact"], "P enters the same compact window",
events=[{"key": "p", "code": 35}])
input_request(first_pid, 1, lambda state: state["compact"], "held P cannot exit compact mode",
events=[{"key": "p", "code": 35, "repeat": True}])
input_request(first_pid, 1, lambda state: not state["compact"], "same P restores normal mode",
events=[{"key": "p", "code": 35}])
config.parent.mkdir(parents=True) config.parent.mkdir(parents=True)
config_text = '"show_buffer_info" = true # optional display\n automatic_quality = true\nsnap_to_edges = false\n' config_text = ('automatic_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) config.write_text(config_text)
invoke(["--new-instance"]) invoke(["--new-instance"])
second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0 second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0
@@ -92,6 +119,35 @@ def check_cli_instances(root, app, fixtures):
empty_pid = next(pid for pid in second if pid != first_pid) empty_pid = next(pid for pid in second if pid != first_pid)
if settings(second[first_pid]) != (False, False, True) or config.read_text() != config_text: if settings(second[first_pid]) != (False, False, True) or config.read_text() != config_text:
raise RuntimeError(f"config loading changed an existing session or wrote the file: {second}") raise RuntimeError(f"config loading changed an existing session or wrote the file: {second}")
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured player loads real video",
media=movie)
input_request(empty_pid, 2, lambda state: state["status"] == 2, "configured X pauses actual playback",
events=[{"key": "x"}])
input_request(empty_pid, 2, lambda state: state["status"] == 2, "old K binding is removed",
events=[{"key": "k", "code": 40}])
input_request(empty_pid, 2, lambda state: state["status"] == 2 and state["editorText"] == "x",
"typing X reaches the editor without controlling playback",
events=[{"key": "x", "editing": True}])
input_request(empty_pid, 2, lambda state: state["status"] == 2, "configured Cmd+8 leaves text editing alone",
events=[{"key": "8", "code": 28, "flags": 1 << 20, "editing": True}])
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured Cmd+X resumes actual playback",
events=[{"key": "x", "flags": 1 << 20}])
input_request(empty_pid, 2, lambda state: state["status"] == 3, "held modified playback chord does not toggle",
events=[{"key": "x", "flags": 1 << 20, "repeat": True}])
input_request(empty_pid, 2, lambda state: state["status"] == 2, "same Cmd+X pauses actual playback",
events=[{"key": "x", "flags": 1 << 20}])
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured native menu Cmd+8 resumes",
events=[{"key": "8", "code": 28, "flags": 1 << 20, "menu": True}])
input_request(empty_pid, 2, lambda state: state["status"] == 2, "native Pause item applies current state",
events=[{"select": "Pause"}])
input_request(empty_pid, 2, lambda state: state["status"] == 3, "native Play item resumes the same player",
events=[{"select": "Play"}])
input_request(empty_pid, 2, lambda state: not state["showBufferInfo"], "paired B hides buffer info",
events=[{"key": "b", "code": 11}])
input_request(empty_pid, 2, lambda state: state["showBufferInfo"], "same B shows buffer info",
events=[{"key": "b", "code": 11}])
input_request(empty_pid, 2, lambda state: state["status"] == 0, "configured close-file menu restores empty player",
events=[{"key": "w", "code": 13, "flags": (1 << 20) | (1 << 17), "menu": True}])
invoke(["--new-instance", str(audio)]) invoke(["--new-instance", str(audio)])
third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3 third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3
and state["hasAudio"] and not state["hasVideo"] and state["hasAudio"] and not state["hasVideo"]
@@ -99,7 +155,7 @@ def check_cli_instances(root, app, fixtures):
"independent local media reaches only the freshly launched process") "independent local media reaches only the freshly launched process")
if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]: if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]:
raise RuntimeError(f"independent launch disturbed an existing player: {third}") raise RuntimeError(f"independent launch disturbed an existing player: {third}")
config.write_text("show_buffer_info = [\n") config.write_text("[ui]\nbuffer_info = [\n")
failure = invoke(["--new-instance"], expected_code=78) failure = invoke(["--new-instance"], expected_code=78)
if str(config) not in failure.stderr: if str(config) not in failure.stderr:
raise RuntimeError(f"configuration failure omitted its path: {failure.stderr}") raise RuntimeError(f"configuration failure omitted its path: {failure.stderr}")
+81 -2
View File
@@ -65,6 +65,29 @@ private enum AppStartupCheck {
@MainActor @MainActor
private enum CLIInstanceCheck { private enum CLIInstanceCheck {
static var timer: Timer? static var timer: Timer?
static var requestID = ""
static var editorText = ""
static func selectMenuItem(_ title: String, in menu: NSMenu) -> Bool {
for (index, item) in menu.items.enumerated() {
if item.title == title, item.action != nil {
precondition(item.isEnabled, "Native menu action \(title) must be available")
menu.performActionForItem(at: index)
return true
}
if let submenu = item.submenu, selectMenuItem(title, in: submenu) { return true }
}
return false
}
static func prepareMenu(_ menu: NSMenu) {
// Direct key-equivalent dispatch does not open/track the native menu first.
// Ask AppKit to prepare and validate its real items just as normal menu use does.
menu.delegate?.menuNeedsUpdate?(menu)
menu.update()
for item in menu.items {
if let submenu = item.submenu { prepareMenu(submenu) }
}
}
static func run(_ configuration: URL) { static func run(_ configuration: URL) {
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String] let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
@@ -77,14 +100,66 @@ private enum CLIInstanceCheck {
} }
// Exercise the real SwiftUI scene and delegate without displaying test windows. // Exercise the real SwiftUI scene and delegate without displaying test windows.
_ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification, object: nil, queue: .main) { _ in _ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification, object: nil, queue: .main) { _ in
MainActor.assumeIsolated { for window in NSApp.windows { window.alphaValue = 0 } } MainActor.assumeIsolated {
for window in NSApp.windows {
window.alphaValue = 0
window.ignoresMouseEvents = true
}
}
} }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { MainActor.assumeIsolated {
let model = PlayerModel.shared let model = PlayerModel.shared
let pid = ProcessInfo.processInfo.processIdentifier let pid = ProcessInfo.processInfo.processIdentifier
let windows = NSApp.windows.filter { !($0 is NSPanel) && $0.contentView != nil } // Text input can create non-main auxiliary windows; count actual player windows.
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil }
for window in windows { window.alphaValue = 0 } for window in windows { window.alphaValue = 0 }
let request = directory.appendingPathComponent("keys-\(pid).json")
if let data = try? Data(contentsOf: request),
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = command["id"] as? String, id != requestID, let window = windows.first {
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
NSApp.activate()
window.makeKeyAndOrderFront(nil)
window.makeMain()
return
}
requestID = id
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
if let events = command["events"] as? [[String: Any]] {
window.makeKey()
for input in events {
if let title = input["select"] as? String {
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
prepareMenu(menu)
precondition(selectMenuItem(title, in: menu), "Native menu action \(title) must exist")
continue
}
let editor = NSTextView(frame: .zero)
if input["editing"] as? Bool == true {
window.contentView?.addSubview(editor)
window.makeFirstResponder(editor)
} else { window.makeFirstResponder(nil) }
let text = input["key"] as! String
let event = NSEvent.keyEvent(with: .keyDown, location: .zero,
modifierFlags: NSEvent.ModifierFlags(rawValue: UInt(input["flags"] as? Int ?? 0)),
timestamp: 0, windowNumber: window.windowNumber, context: nil,
characters: text, charactersIgnoringModifiers: text,
isARepeat: input["repeat"] as? Bool ?? false,
keyCode: UInt16(input["code"] as? Int ?? 7))!
if input["menu"] as? Bool == true {
if let menu = NSApp.mainMenu { prepareMenu(menu) }
precondition(NSApp.mainMenu?.performKeyEquivalent(with: event) == true,
"Configured shortcut must be accepted by the native menu")
} else { NSApp.sendEvent(event) }
if input["editing"] as? Bool == true {
editorText = editor.string
window.makeFirstResponder(nil)
editor.removeFromSuperview()
}
}
}
}
let applications = NSWorkspace.shared.runningApplications.filter { let applications = NSWorkspace.shared.runningApplications.filter {
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL !$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
} }
@@ -101,6 +176,10 @@ private enum CLIInstanceCheck {
"showBufferInfo": model.showsBufferInfo, "showBufferInfo": model.showsBufferInfo,
"automaticQuality": model.automaticYouTubeQuality, "automaticQuality": model.automaticYouTubeQuality,
"snapToEdges": model.compactWindow.snappingEnabled, "snapToEdges": model.compactWindow.snappingEnabled,
"requestID": requestID,
"editorText": editorText,
"compact": model.isCompact,
"onTop": model.isOnTop,
] ]
do { do {
let data = try JSONSerialization.data(withJSONObject: snapshot) let data = try JSONSerialization.data(withJSONObject: snapshot)
+143 -17
View File
@@ -11,18 +11,96 @@ struct ConfigurationChecks {
if !condition { throw Failure(description: "FAIL: " + message) } if !condition { throw Failure(description: "FAIL: " + message) }
} }
static func requireLoadError(at url: URL, _ message: String) throws { static func requireLoadError(at url: URL, _ message: String, containing fragments: [String] = []) throws {
do { do {
_ = try PlayerConfiguration.load(from: url) _ = try PlayerConfiguration.load(from: url)
} catch let caught { } catch let caught {
let diagnostic = caught.localizedDescription + " " + String(describing: caught) let diagnostic = caught.localizedDescription + " " + String(describing: caught)
try require(diagnostic.contains(url.path), message + " identifies the actual config path") try require(diagnostic.contains(url.path), message + " identifies the actual config path")
for fragment in fragments {
try require(diagnostic.contains(fragment), message + " identifies '\(fragment)': " + diagnostic)
}
return return
} }
throw Failure(description: "FAIL: " + message + " was accepted") throw Failure(description: "FAIL: " + message + " was accepted")
} }
static func requireError(_ fragments: [String], _ operation: () throws -> Void) throws {
do { try operation() }
catch {
for fragment in fragments {
try require(error.localizedDescription.contains(fragment), "error identifies '\(fragment)': \(error)")
}
return
}
throw Failure(description: "FAIL: invalid binding was accepted")
}
static func checkBindings() throws {
let bindings = KeyBindings()
let inversePairs: [(PlayerAction, PlayerAction)] = [
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
(.enableAutomaticQuality, .disableAutomaticQuality)
]
for (first, second) in inversePairs {
try require(first.inverse == second && second.inverse == first, "symmetric inverses")
let overrides = try KeyBindings(overrides: [first.name: ["ctrl+alt+x"], second.name: ["ctrl+alt+x"]])
try require(Set(overrides.actions(for: try KeyChord("ctrl+alt+x"))) == [first, second],
"every known inverse pair may share a chord")
}
for height in [1, 144, 720, 1080, 2160, 4320] {
let action = PlayerAction.quality(height)
try require(PlayerAction(name: "quality_\(height)p") == action && action.name == "quality_\(height)p",
"explicit positive quality height round-trips")
try require(action.inverse == nil && !action.allowsRepeat && bindings.shortcuts(for: action).isEmpty,
"quality actions default to unbound and non-repeating")
}
for name in ["", "Play", "toggle_play", "toggle_fullscreen", "captions", "enable_captions", "disable_captions",
"quality_0p", "quality_-720p", "quality_0720p", "quality_720", "quality_720P", "quality_7.2p",
"quality_999999999999999999999999999p"] {
try require(PlayerAction(name: name) == nil, "unsupported action \(name) rejected")
try requireError(name.isEmpty ? [] : [name]) { _ = try KeyBindings(overrides: [name: []]) }
}
let normalized = try KeyChord("SHIFT+CMD+Option+CTRL+A")
try require(normalized == KeyChord(key: "a", modifiers: [.shift, .command, .option, .control]),
"letter casing and modifier order normalize")
try require(try KeyChord(normalized.description) == normalized, "canonical description round-trips")
try require(try KeyChord("option+x") == KeyChord("alt+x"), "option alias normalizes")
try require(try KeyChord("plus").key == "+" && KeyChord("cmd+plus").description == "cmd+plus",
"named plus identifies the printable + key")
for key in ["space", "escape", "return", "tab", "backspace", "delete", "left", "right", "up", "down",
"home", "end", "pageup", "pagedown", ".", ",", "é"] + (1...35).map({ "f\($0)" }) {
let chord = try KeyChord(key)
try require(try KeyChord(chord.description) == chord, "\(key) parses and round-trips")
}
for chord in ["", "+", "cmd+", "+a", "cmd++a", "cmd+cmd+a", "alt+option+a", "super+a",
"cmd+wat", "f0", "f36", "f01", " ", "\n", "a+b", "media_play", "cmd+tab", "cmd+shift+tab"] {
try requireError(chord.isEmpty ? [] : [chord]) { _ = try KeyChord(chord) }
}
let merged = try KeyBindings(overrides: ["play": ["x", "ctrl+x"], "pause": ["x"], "mute": [],
"quality_720p": ["alt+7"], "quality_default": ["alt+0"]])
try require(merged.shortcuts(for: .play) == [KeyChord("x"), KeyChord("ctrl+x")], "array replaces, not appends")
try require(merged.shortcuts(for: .mute).isEmpty && merged.actions(for: KeyChord("m")) == [.unmute],
"empty array unbinds only its action")
try require(merged.actions(for: KeyChord("k")).isEmpty && merged.shortcuts(for: .openFile) == bindings.shortcuts(for: .openFile),
"replaced shortcuts disappear while omitted actions retain defaults")
try require(merged.actions(for: KeyChord("alt+7")) == [.quality(720)]
&& merged.actions(for: KeyChord("alt+0")) == [.qualityDefault], "quality shortcuts indexed")
try require(bindings.actions(for: KeyChord("cmd+k")).isEmpty, "modifiers match exactly")
try require(try KeyBindings(overrides: ["show_buffer_info": []]) == bindings, "unbinding an already-unbound action is unchanged")
try requireError(["play", "k"]) { _ = try KeyBindings(overrides: ["play": ["k", "K"]]) }
try requireError(["play", "ctrl+cmd+x"]) { _ = try KeyBindings(overrides: ["play": ["ctrl+cmd+x", "cmd+ctrl+X"]]) }
try requireError(["play", "quit", "q"]) { _ = try KeyBindings(overrides: ["play": ["q"]]) }
try requireError(["play", "mute", "x"]) { _ = try KeyBindings(overrides: ["play": ["x"], "mute": ["x"]]) }
try requireError(["quality_720p", "quality_1080p", "alt+7"]) {
_ = try KeyBindings(overrides: ["quality_720p": ["alt+7"], "quality_1080p": ["alt+7"]])
}
}
static func main() throws { static func main() throws {
try checkBindings()
let files = FileManager.default let files = FileManager.default
let root = files.temporaryDirectory.appendingPathComponent("terebi.configuration-check.\(UUID().uuidString)") let root = files.temporaryDirectory.appendingPathComponent("terebi.configuration-check.\(UUID().uuidString)")
try files.createDirectory(at: root, withIntermediateDirectories: true) try files.createDirectory(at: root, withIntermediateDirectories: true)
@@ -47,16 +125,18 @@ struct ConfigurationChecks {
try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true) try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true)
let original = Data(""" let original = Data("""
# Root keys can be quoted, with comments and whitespace. # Root and nested keys can be quoted, with comments and whitespace.
"show_buffer_info" = true # session display default 'automatic_quality' = true
'automatic_quality' = true [ui]
snap_to_edges = false "buffer_info" = true # session display default
compact_snap_to_edges = false
""".utf8) """.utf8)
try original.write(to: config) try original.write(to: config)
try files.setAttributes([.posixPermissions: 0o444], ofItemAtPath: config.path)
let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false) let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false)
let environment = ["XDG_CONFIG_HOME": xdg.path] let environment = ["XDG_CONFIG_HOME": xdg.path]
try require(try PlayerConfiguration.load(from: config) == configured, try require(try PlayerConfiguration.load(from: config) == configured,
"TOML comments, whitespace, quoted root keys, and both boolean values load") "TOML comments, whitespace, quoted nested keys, and both boolean values load from a read-only file")
var session = try PlayerConfiguration.loadForApplication(arguments: [], environment: environment, homeDirectory: home) var session = try PlayerConfiguration.loadForApplication(arguments: [], environment: environment, homeDirectory: home)
try require(session == configured, "application startup automatically loads the XDG config") try require(session == configured, "application startup automatically loads the XDG config")
session.showBufferInfo = false session.showBufferInfo = false
@@ -76,21 +156,67 @@ struct ConfigurationChecks {
try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", partial.absoluteString], try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", partial.absoluteString],
environment: environment, homeDirectory: home) environment: environment, homeDirectory: home)
== PlayerConfiguration(automaticQuality: true), "explicit file URL overrides the resolved XDG config") == PlayerConfiguration(automaticQuality: true), "explicit file URL overrides the resolved XDG config")
try require(try Data(contentsOf: config) == original, "read-only file bytes remain unchanged")
let nested = root.appendingPathComponent("nested.toml")
try Data("""
[ui]
buffer_info = true
[keys]
play = "x"
pause = ["x", "ctrl+x"]
mute = []
quality_1080p = "alt+1"
show_buffer_info = "alt+b"
hide_buffer_info = "alt+b"
""".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")
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"]])
try require(nestedConfiguration.keys == expectedKeys, "heterogeneous string, array, and empty shortcuts decode")
var keySession = nestedConfiguration
keySession.keys = KeyBindings()
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 require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
let invalid: [(String, String)] = [ let invalid: [(String, String, [String])] = [
("syntax", "show_buffer_info = [\n"), ("syntax", "automatic_quality = [\n", ["automatic_quality"]),
("duplicate", "show_buffer_info = true\n\"show_buffer_info\" = false\n"), ("duplicate", "automatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
("unknown", "unknown_setting = true\n"), ("unknown", "unknown_setting = true\n", ["unknown_setting"]),
("string", "show_buffer_info = \"true\"\n"), ("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
("integer", "automatic_quality = 1\n"), ("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
("array", "snap_to_edges = [true]\n"), ("ui-unknown", "[ui]\nunknown_setting = true\n", ["ui.unknown_setting"]),
("table", "[show_buffer_info]\nenabled = true\n") ("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
("string", "[ui]\nbuffer_info = \"true\"\n", []),
("integer", "automatic_quality = 1\n", []),
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
("table", "[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"]),
("key-int", "[keys]\nplay = 42\n", ["keys.play"]),
("key-mixed-array", "[keys]\nplay = [\"x\", 42]\n", ["play"]),
("key-table", "[keys.play]\nkey = \"x\"\n", ["keys.play"]),
("key-unknown", "[keys]\nunknown_action = \"x\"\n", ["keys.unknown_action"]),
("key-toggle", "[keys]\ntoggle_play = \"x\"\n", ["keys.toggle_play"]),
("key-captions", "[keys]\nenable_captions = []\n", ["keys.enable_captions"]),
("key-quality", "[keys]\nquality_0p = \"alt+0\"\n", ["keys.quality_0p"]),
("key-chord", "[keys]\nplay = \"cmd++x\"\n", ["keys.play", "cmd++x"]),
("key-duplicate", "[keys]\nplay = [\"x\", \"X\"]\n", ["keys.play", "duplicate", "X"]),
("key-default-conflict", "[keys]\nplay = \"q\"\n", ["keys.play", "keys.quit", "q"]),
("key-explicit-conflict", "[keys]\nplay = \"x\"\nmute = \"x\"\n", ["keys.play", "keys.mute", "x"]),
("key-reserved", "[keys]\nplay = \"cmd+tab\"\n", ["keys.play", "cmd+tab", "unsupported"])
] ]
for (name, text) in invalid { for (name, text, fragments) in invalid {
let url = root.appendingPathComponent(name + ".toml") let url = root.appendingPathComponent(name + ".toml")
let bytes = Data(text.utf8) let bytes = Data(text.utf8)
try bytes.write(to: url) try bytes.write(to: url)
try requireLoadError(at: url, "invalid \(name) configuration") try requireLoadError(at: url, "invalid \(name) configuration", containing: fragments)
try require(try Data(contentsOf: url) == bytes, "rejected \(name) config remains unchanged") try require(try Data(contentsOf: url) == bytes, "rejected \(name) config remains unchanged")
} }
let invalidUTF8 = root.appendingPathComponent("invalid-utf8.toml") let invalidUTF8 = root.appendingPathComponent("invalid-utf8.toml")
@@ -112,7 +238,7 @@ struct ConfigurationChecks {
let badXDG = root.appendingPathComponent("bad-xdg") let badXDG = root.appendingPathComponent("bad-xdg")
let badConfig = badXDG.appendingPathComponent("terebi/config.toml") let badConfig = badXDG.appendingPathComponent("terebi/config.toml")
try files.createDirectory(at: badConfig.deletingLastPathComponent(), withIntermediateDirectories: true) try files.createDirectory(at: badConfig.deletingLastPathComponent(), withIntermediateDirectories: true)
let malformedBytes = Data("snap_to_edges =\n".utf8) let malformedBytes = Data("[ui]\ncompact_snap_to_edges =\n".utf8)
try malformedBytes.write(to: badConfig) try malformedBytes.write(to: badConfig)
try require(try PlayerConfiguration.loadForApplication(arguments: ["--no-config"], try require(try PlayerConfiguration.loadForApplication(arguments: ["--no-config"],
environment: ["XDG_CONFIG_HOME": badXDG.path], homeDirectory: home) == defaults, environment: ["XDG_CONFIG_HOME": badXDG.path], homeDirectory: home) == defaults,
+10
View File
@@ -62,6 +62,16 @@ struct SystemPlaybackChecks {
require(model.state.position == pausedPosition, "repeated pause preserves position") require(model.state.position == pausedPosition, "repeated pause preserves position")
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0, require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
"paused system timeline does not extrapolate") "paused system timeline does not extrapolate")
let playbackKey = try! KeyChord("space")
require(model.handleKey(playbackKey) && model.effectivePlaying,
"play binding records intent before backend acknowledgement")
require(model.handleKey(playbackKey) && !model.effectivePlaying,
"same binding resolves pause against pending play intent")
require(model.handleKey(playbackKey, isRepeat: true) && !model.effectivePlaying,
"held paired shortcut is consumed without oscillating")
until("rapid opposite intents settle to paused") { model.state.status == PLAYER_PAUSED }
require(!model.perform(.pause) && !model.effectivePlaying,
"explicit pause cannot resume an already paused player")
let seekableState = model.state let seekableState = model.state
var nonseekableState = seekableState var nonseekableState = seekableState
+3 -3
View File
@@ -10,7 +10,7 @@ PREFIX ?= $(HOME)/.local
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/Output.o OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/Output.o
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE) CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
CONFIG_BUILD := .build/configuration CONFIG_BUILD := .build/configuration
@@ -46,12 +46,12 @@ $(BUILD_DIR):
Package.resolved: Package.swift Package.resolved: Package.swift
xcrun swift package --scratch-path $(CONFIG_BUILD) resolve xcrun swift package --scratch-path $(CONFIG_BUILD) resolve
$(CONFIG_LIBRARY): Package.swift Package.resolved Sources/Configuration/PlayerConfiguration.swift $(CONFIG_LIBRARY): Package.swift Package.resolved $(wildcard Sources/Configuration/*.swift)
xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --product TerebiConfiguration xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --product TerebiConfiguration
$(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY) $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
mkdir -p "$(APP)/Contents/Resources" mkdir -p "$(APP)/Contents/Resources"
cp "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@" cp -f "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@"
$(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Makefile | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
+51 -22
View File
@@ -61,15 +61,54 @@ Help exits with status **0** without opening the GUI, reading configuration, or
Every fresh app startup automatically reads **`$XDG_CONFIG_HOME/terebi/config.toml`**, falling back to **`~/.config/terebi/config.toml`** when `XDG_CONFIG_HOME` is unset, empty, or not absolute. The CLI forwards its resolved path because Finder/LaunchServices does not inherit the shell's XDG environment. Set `XDG_CONFIG_HOME` in the GUI launch environment if Finder should use a different directory. Every fresh app startup automatically reads **`$XDG_CONFIG_HOME/terebi/config.toml`**, falling back to **`~/.config/terebi/config.toml`** when `XDG_CONFIG_HOME` is unset, empty, or not absolute. The CLI forwards its resolved path because Finder/LaunchServices does not inherit the shell's XDG environment. Set `XDG_CONFIG_HOME` in the GUI launch environment if Finder should use a different directory.
The file is optional: a missing file means built-in defaults, and terebi never creates a file or directory on its own. These are all supported settings and their defaults: 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 ```toml
show_buffer_info = false
automatic_quality = false automatic_quality = false
snap_to_edges = true
[ui]
buffer_info = false
compact_snap_to_edges = true
[keys]
play = ["k", "space"]
pause = ["k", "space"]
mute = "m"
unmute = "m"
volume_up = "up"
volume_down = "down"
seek_forward_5s = "right"
seek_backward_5s = "left"
seek_forward_10s = "l"
seek_backward_10s = "j"
step_frame_forward = "."
step_frame_backward = ","
enter_fullscreen = ["f", "ctrl+cmd+f"]
exit_fullscreen = ["f", "ctrl+cmd+f", "escape"]
enter_compact = ["p", "ctrl+cmd+p"]
exit_compact = ["p", "ctrl+cmd+p"]
enable_always_on_top = "shift+p"
disable_always_on_top = "shift+p"
open_file = ["o", "cmd+o"]
open_youtube = "cmd+shift+o"
close_file = "cmd+shift+w"
quit = ["q", "cmd+q"]
show_buffer_info = []
hide_buffer_info = []
enable_compact_snapping = []
disable_compact_snapping = []
enable_automatic_quality = []
disable_automatic_quality = []
quality_default = []
``` ```
Omitted keys retain their defaults. 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. 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. 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.
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. 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.
@@ -89,7 +128,7 @@ Seekable network playback targets **60 seconds ahead and 30 seconds behind** usi
Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, continuing sequentially and reusing persistent connections when the server permits. This avoids the open-ended-request throttling seen on some YouTube CDNs; URL extraction alone does not apply yt-dlp's download chunking. Native [`request_size` and `multiple_requests`](https://github.com/FFmpeg/FFmpeg/blob/n9.0.1/doc/protocols.texi#L462-L485) handle continuation without a separate downloader. Large `short_seek_size` read-ahead stays disabled: an uncached seek must escape a stalled response, not wait to drain it. Required headers, fifteen-second I/O deadlines, cache limits, and cancellation remain intact. Live sources keep their existing transport behavior. This removes avoidable request-level throttling, not bandwidth/CDN limits: a minute is a target, not a startup requirement or guaranteed reserve. Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, continuing sequentially and reusing persistent connections when the server permits. This avoids the open-ended-request throttling seen on some YouTube CDNs; URL extraction alone does not apply yt-dlp's download chunking. Native [`request_size` and `multiple_requests`](https://github.com/FFmpeg/FFmpeg/blob/n9.0.1/doc/protocols.texi#L462-L485) handle continuation without a separate downloader. Large `short_seek_size` read-ahead stays disabled: an uncached seek must escape a stalled response, not wait to drain it. Required headers, fifteen-second I/O deadlines, cache limits, and cancellation remain intact. Live sources keep their existing transport behavior. This removes avoidable request-level throttling, not bandwidth/CDN limits: a minute is a target, not a startup requirement or guaranteed reserve.
**gear → Show Buffer Info** enables the optional bottom readout in both player modes; it is **disabled by default**, configurable with `show_buffer_info`, and its menu toggle is session-only. When enabled, actual retained times appear as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead regardless of this preference; its tooltip and VoiceOver retain fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Hiding the readout does not affect caching or automatic quality. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range. **gear → Show Buffer Info** enables the optional bottom readout in both player modes; it is **disabled by default**, configurable with `[ui].buffer_info`, and its menu toggle is session-only. When enabled, actual retained times appear as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead regardless of this preference; its tooltip and VoiceOver retain fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Hiding the readout does not affect caching or automatic quality. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range.
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. 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.
@@ -113,20 +152,9 @@ Opening video fits the window to its display aspect ratio, including non-square
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available. **Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available.
Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app. Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `[ui].compact_snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app.
| Key | Action | Default shortcuts and their explicit action names are listed in **Configuration**. Bindings apply only to the playback window and do not intercept text editing, file/URL dialogs, plain Tab navigation, or VoiceOver's Control+Option commands. Configured modifier combinations work in playback. Holding a playback/window/settings key performs it once; seek, volume, and frame-step keys may repeat. Player menus show the first configured chord and use the same explicit actions rather than retaining hidden fixed player shortcuts; all configured chords work from playback. Return/Space navigation in an open menu activates its selected action.
|---|---|
| `K` / `Space` | Toggle playback |
| `M` | Toggle mute, restoring the previous audible level |
| `J` / `L` | Seek backward / forward ten seconds |
| `,` / `.` | Step to the previous / next video frame; leave playback paused |
| `F` | Toggle fullscreen |
| `P` | Toggle keep-on-top (windowed mode) |
| `O` | Open the file picker to switch files |
| `Q` | Quit through normal application shutdown |
Single-key shortcuts apply only to the playback window and do not intercept text editing, file-picker input, or Command/Control/Option combinations. Held toggle keys do not repeatedly toggle; held `J`/`L` continue seeking and held `,`/`.` continue stepping. `C` captions remains unbound.
macOS media keys and Now Playing controls use native MediaPlayer commands, including while the window is unfocused: play/pause toggles playback, and previous/next seeks backward/forward five seconds instead of switching tracks. System skip controls prefer five-second intervals. Live/non-seekable inputs disable seeking while retaining play/pause. Now Playing reports the current title, duration, elapsed time, and playback state; opening/replacing, closing, failed playback, and quitting release unavailable controls. No Accessibility or Input Monitoring permission is required. macOS chooses which active media app receives the keys. macOS media keys and Now Playing controls use native MediaPlayer commands, including while the window is unfocused: play/pause toggles playback, and previous/next seeks backward/forward five seconds instead of switching tracks. System skip controls prefer five-second intervals. Live/non-seekable inputs disable seeking while retaining play/pause. Now Playing reports the current title, duration, elapsed time, and playback state; opening/replacing, closing, failed playback, and quitting release unavailable controls. No Accessibility or Input Monitoring permission is required. macOS chooses which active media app receives the keys.
@@ -148,6 +176,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti
- `Sources/Core/network_input.hpp`: network-only demux readers with a shared compressed-byte budget, playback-anchored read-ahead/history, keyframe/audio-preroll seek indices, conservative reordered-video timestamp frontiers, cached replay, asynchronous miss seeks, fifteen-second I/O deadlines, and interruption on miss/replacement/close/destruction. Cache hits leave outstanding fetch-frontier I/O intact. Remote input protocols exclude local-file access through manifests. - `Sources/Core/network_input.hpp`: network-only demux readers with a shared compressed-byte budget, playback-anchored read-ahead/history, keyframe/audio-preroll seek indices, conservative reordered-video timestamp frontiers, cached replay, asynchronous miss seeks, fifteen-second I/O deadlines, and interruption on miss/replacement/close/destruction. Cache hits leave outstanding fetch-frontier I/O intact. Remote input protocols exclude local-file access through manifests.
- `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely. - `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely.
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, manual/automatic YouTube quality switching with position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control. - `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, manual/automatic YouTube quality switching with position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control.
- `Sources/App/PlayerActions.swift`: native event-to-chord adaptation, editing/accessibility eligibility, explicit context-menu dispatch, and scene-focused SwiftUI commands sharing the model's action bindings.
- `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do. - `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do.
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source. - `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source.
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup. - `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
@@ -155,7 +184,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation. - `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Launch the app without media first to establish its singleton window, then deliver the selected URL; the app owns Apple events to prevent SwiftUI's default external-document routing from replacing that window. - `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Launch the app without media first to establish its singleton window, then deliver the selected URL; the app owns Apple events to prevent SwiftUI's default external-document routing from replacing that window.
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the library's actual linked FFmpeg ABI paths. - `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the library's actual linked FFmpeg ABI paths.
- `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, XDG path resolution, read-only strict TOML loading, and startup bypass. `Package.swift`/`Package.resolved` pin the statically linked decoder. - `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, nested behavior/UI/key schema, XDG path resolution, read-only strict TOML loading, and startup bypass. `PlayerAction.swift` defines explicit supported actions/inverse pairs; `KeyBindings.swift` parses chords and validates/indexes the complete binding map. `Package.swift`/`Package.resolved` pin the statically linked decoder.
- `Makefile`: compiler/linker commands, SwiftPM configuration-library build, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project. - `Makefile`: compiler/linker commands, SwiftPM configuration-library build, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project.
## Runnable check ## Runnable check
@@ -170,7 +199,7 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app. `make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented TOML, invalid syntax/types/keys/UTF-8, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration. `make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent. `make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent.
@@ -184,9 +213,9 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified. `make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified.
`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app. `make check-media` exercises actual playback through the app model, idempotent play/pause, immediate and rapid opposite playback intents, held-key suppression, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production startup/SwiftUI scene/delegate: missing-config defaults without creation, configured controls, malformed-file exit 78, cold/warm `--no-config`, unchanged file bytes and existing session settings, cold/default reuse, independent empty/local/YouTube launches, exactly one window per PID, media delivered only to the new process, original playback retained, help without launch, and warm default replacement. YouTube uses loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent, audio muted, and configuration/bundle/history isolated. It does not require Vorssaint or touch real playback history. Finder/Space behavior and Vorssaint discovery still need a check in the visible app. `make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production startup/SwiftUI scene/delegate: missing-config defaults without creation, nested settings/key overrides, actual plain/modified shortcuts, rapid opposite intents and held-key suppression, same-window compact entry/restoration, removed old bindings, real text insertion without playback interception, native menu equivalents and Play/Pause selections, malformed-file exit 78, cold/warm `--no-config`, unchanged file bytes and existing session settings, cold/default reuse, independent empty/local/YouTube launches, exactly one player window per PID, targeted media, retained original playback, help without launch, and warm default replacement. YouTube uses loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent and ignore mouse input, audio muted, and configuration/bundle/history isolated. Native menus receive their normal lazy-population/validation callbacks before direct selection. Finder/Space behavior, visible controls/menu presentation and Vorssaint discovery still need a check in the app.
Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks. Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks.
+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 { switch action {
case .play: accepted = model.setPlayback(playing: true) case .play: accepted = model.setPlayback(playing: true)
case .pause: accepted = model.setPlayback(playing: false) 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) case let .skip(seconds): accepted = model.seekRelative(seconds)
} }
return accepted ? .success : .commandFailed 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 isFullScreen = false
@Published private(set) var isOnTop = false @Published private(set) var isOnTop = false
let compactWindow: CompactWindowController 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 isCompact: Bool { compactWindow.isCompact }
var canToggleCompact: Bool { var canToggleCompact: Bool {
isCompact || compactWindow.isTransitioning || isCompact || compactWindow.isTransitioning ||
@@ -66,6 +84,7 @@ final class PlayerModel: ObservableObject {
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(), init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) { qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock self.qualityClock = qualityClock
keyBindings = configuration.keys
automaticYouTubeQuality = configuration.automaticQuality automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges) compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
@@ -80,7 +99,14 @@ final class PlayerModel: ObservableObject {
videoView = nil videoView = nil
commandError = "Could not create the media player." 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 timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.poll() } MainActor.assumeIsolated { self?.poll() }
} }
@@ -187,7 +213,7 @@ final class PlayerModel: ObservableObject {
} }
let current = player_get_state(handle) let current = player_get_state(handle)
let position = !isLiveStream && current.can_seek ? current.position : nil 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, private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
@@ -200,6 +226,10 @@ final class PlayerModel: ObservableObject {
revealControls() revealControls()
return return
} }
if !automatic {
playbackIntent = nil
playbackInFlight = nil
}
let request = UUID() let request = UUID()
youtubeRequest = request youtubeRequest = request
resolvingYouTube = true resolvingYouTube = true
@@ -296,6 +326,8 @@ final class PlayerModel: ObservableObject {
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false, private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) { restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
guard let handle else { return } guard let handle else { return }
playbackIntent = nil
playbackInFlight = nil
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
pendingRevision = revision pendingRevision = revision
fileName = title fileName = title
@@ -347,7 +379,7 @@ final class PlayerModel: ObservableObject {
} }
func togglePlayback() { func togglePlayback() {
setPlayback(playing: state.status != PLAYER_PLAYING) perform(playbackAction)
} }
@discardableResult @discardableResult
@@ -355,7 +387,18 @@ final class PlayerModel: ObservableObject {
guard let handle, canTogglePlayback else { return false } guard let handle, canTogglePlayback else { return false }
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() } if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() } 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) 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." commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls() revealControls()
return accepted return accepted
@@ -382,6 +425,9 @@ final class PlayerModel: ObservableObject {
commandError = "Could not queue closing the file. Please try again." commandError = "Could not queue closing the file. Please try again."
return return
} }
playbackIntent = nil
playbackInFlight = nil
compactIntent = nil
compactWindow.restore(resumeFullScreen: false) compactWindow.restore(resumeFullScreen: false)
fileName = nil fileName = nil
fileIcon = nil fileIcon = nil
@@ -420,12 +466,18 @@ final class PlayerModel: ObservableObject {
return accepted return accepted
} }
func stepFrame(_ direction: Int32) { @discardableResult
guard canStepFrame, let handle else { return } func stepFrame(_ direction: Int32) -> Bool {
guard canStepFrame, let handle else { return false }
let accepted = player_step_frame(handle, direction) 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." commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls() revealControls()
return accepted
} }
func setVolume(_ value: Double) { func setVolume(_ value: Double) {
@@ -438,8 +490,7 @@ final class PlayerModel: ObservableObject {
} }
func toggleMute() { func toggleMute() {
guard canSetVolume else { return } perform(muteAction)
setVolume(volume == 0 ? lastAudibleVolume : 0)
} }
func attachWindow(_ window: NSWindow) { func attachWindow(_ window: NSWindow) {
@@ -455,6 +506,7 @@ final class PlayerModel: ObservableObject {
func updateWindowState() { func updateWindowState() {
guard let window else { return } guard let window else { return }
isFullScreen = window.styleMask.contains(.fullScreen) isFullScreen = window.styleMask.contains(.fullScreen)
if !compactWindow.isTransitioning && fullScreenIntent == isFullScreen { fullScreenIntent = nil }
windowActive = window.isKeyWindow windowActive = window.isKeyWindow
if !compactWindow.isTransitioning { if !compactWindow.isTransitioning {
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
@@ -464,25 +516,119 @@ final class PlayerModel: ObservableObject {
} }
func toggleFullScreen() { func toggleFullScreen() {
guard !compactWindow.isTransitioning else { return } perform(fullscreenAction)
if isCompact { compactWindow.restore(resumeFullScreen: false) }
window?.toggleFullScreen(nil)
revealControls()
} }
func toggleCompact() { func toggleCompact() {
guard canToggleCompact else { return } perform(compactAction)
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
titleHovered = false
revealControls()
} }
func toggleOnTop() { func toggleOnTop() {
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return } perform(onTopAction)
isOnTop.toggle() }
window?.level = isOnTop ? .floating : .normal
revealControls() 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) { func setControlsHovered(_ hovered: Bool) {
@@ -594,6 +740,17 @@ final class PlayerModel: ObservableObject {
defer { systemPlayback?.update() } defer { systemPlayback?.update() }
let statusChanged = state.status != next.status let statusChanged = state.status != next.status
state = next 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 || if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
(!isBusy && !next.has_video) { (!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false) compactWindow.restore(resumeFullScreen: false)
@@ -633,7 +790,7 @@ final class PlayerModel: ObservableObject {
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) } let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample( let sample = AutomaticQuality.Sample(
enabled: automaticYouTubeQuality, network: isNetworkMedia, 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, seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream,
buffering: state.buffering, position: state.position, duration: state.duration, buffering: state.buffering, position: state.position, duration: state.duration,
ahead: reserve, height: youtubeHeight, heights: youtubeHeights) ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
@@ -906,52 +1063,22 @@ private struct PlayerContent: View {
private var actionsMenu: some View { private var actionsMenu: some View {
Menu { Menu {
Button("Open File…", action: model.openPanel).disabled(!model.canOpen) PlayerActionButton(model: model, action: .openFile, title: "Open File…")
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen) PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
Button("Close File", action: model.closeFile).disabled(!model.canClose) PlayerActionButton(model: model, action: .closeFile, title: "Close File")
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")
}
Divider() Divider()
Toggle("Show Buffer Info", isOn: Binding( PlayerQualityMenu(model: model)
get: { model.showsBufferInfo },
set: { model.setShowsBufferInfo($0) }))
Divider() Divider()
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact) PlayerActionButton(model: model, action: model.bufferInfoAction,
.disabled(!model.canToggleCompact) title: model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info")
Toggle("Snap to Screen Edges", isOn: Binding(
get: { model.compactWindow.snappingEnabled },
set: { model.compactWindow.snappingEnabled = $0 }))
Divider() Divider()
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) PlayerActionButton(model: model, action: model.compactAction,
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning) 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: { } label: {
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24) 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 { private var compactControls: some View {
VStack(spacing: 4) { VStack(spacing: 4) {
HStack(spacing: 16) { HStack(spacing: 16) {
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill", iconButton(model.effectivePlaying ? "pause.fill" : "play.fill",
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback) model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback) .disabled(!model.canTogglePlayback)
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact) iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
.disabled(model.compactWindow.isTransitioning) .disabled(model.compactWindow.isTransitioning)
@@ -1005,13 +1132,13 @@ private struct PlayerContent: View {
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) } iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
.disabled(!model.canSeek) .disabled(!model.canSeek)
Button(action: model.togglePlayback) { 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)) .font(.system(size: 24))
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play") .accessibilityLabel(model.effectivePlaying ? "Pause" : "Play")
.help(model.state.status == PLAYER_PLAYING ? "Pause (K / Space)" : "Play (K / Space)") .help(model.effectivePlaying ? "Pause" : "Play")
.disabled(!model.canTogglePlayback) .disabled(!model.canTogglePlayback)
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) } iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
.disabled(!model.canSeek) .disabled(!model.canSeek)
@@ -1231,34 +1358,8 @@ private struct WindowChrome: NSViewRepresentable {
required init?(coder: NSCoder) { nil } required init?(coder: NSCoder) { nil }
func handleKeyEvent(_ event: NSEvent) -> Bool { func handleKeyEvent(_ event: NSEvent) -> Bool {
guard event.type == .keyDown, let window = configuredWindow, event.window === window, guard let window = configuredWindow else { return false }
!(window.firstResponder is NSTextView), return model.handleKeyEvent(event, in: window)
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
} }
override func viewDidMoveToWindow() { override func viewDidMoveToWindow() {
@@ -1396,54 +1497,11 @@ struct TerebiApp: App {
var body: some Scene { var body: some Scene {
Window("\(model.fileName ?? "terebi")", id: "terebi") { Window("\(model.fileName ?? "terebi")", id: "terebi") {
PlayerContent(model: model) PlayerContent(model: model)
.focusedSceneObject(model)
} }
.defaultSize(width: 900, height: 560) .defaultSize(width: 900, height: 560)
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
.commands { .commands { PlayerCommands() }
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)
}
}
} }
} }
+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 showBufferInfo: Bool
public var automaticQuality: Bool public var automaticQuality: Bool
public var snapToEdges: 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.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality self.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges self.snapToEdges = snapToEdges
self.keys = keys
} }
public static func fileURL(environment: [String: String], homeDirectory: URL) -> URL { 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)") throw ConfigurationError(message: "\(url.path): \(error.localizedDescription)")
} }
do { do {
let values = try TOMLDecoder(isLenient: false).decode([String: Bool].self, from: data) let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
for key in values.keys { return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
switch key { automaticQuality: values.automaticQuality ?? false,
case "show_buffer_info", "automatic_quality", "snap_to_edges": break snapToEdges: values.ui?.compactSnapToEdges ?? true,
default: throw ConfigurationError(message: "Unknown setting '\(key)'.") keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
}
}
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
} catch { } 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 let context: DecodingError.Context
var missingKey: String?
switch error { switch error {
case DecodingError.dataCorrupted(let value), DecodingError.typeMismatch(_, let value), 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 ConfigurationError: return error.message
case let error as BindingError: return error.message
default: return String(describing: error) default: return String(describing: error)
} }
if let underlying = context.underlyingError { return String(describing: underlying) } var path = context.codingPath.map(\.stringValue)
let path = context.codingPath.map(\.stringValue).joined(separator: ".") if let missingKey, path.last != missingKey { path.append(missingKey) }
return path.isEmpty ? context.debugDescription : "\(path): \(context.debugDescription)" 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 { private struct ConfigurationError: LocalizedError {