feat: configure explicit state-dependent player keybindings
This commit is contained in:
+58
-2
@@ -48,6 +48,17 @@ def check_cli_instances(root, app, fixtures):
|
||||
def settings(state):
|
||||
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:
|
||||
if extractor and runtime:
|
||||
metadata = root / "youtube.json"
|
||||
@@ -80,9 +91,25 @@ def check_cli_instances(root, app, fixtures):
|
||||
invoke([])
|
||||
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
|
||||
"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_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)
|
||||
invoke(["--new-instance"])
|
||||
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)
|
||||
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}")
|
||||
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)])
|
||||
third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3
|
||||
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")
|
||||
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}")
|
||||
config.write_text("show_buffer_info = [\n")
|
||||
config.write_text("[ui]\nbuffer_info = [\n")
|
||||
failure = invoke(["--new-instance"], expected_code=78)
|
||||
if str(config) not in failure.stderr:
|
||||
raise RuntimeError(f"configuration failure omitted its path: {failure.stderr}")
|
||||
|
||||
@@ -65,6 +65,29 @@ private enum AppStartupCheck {
|
||||
@MainActor
|
||||
private enum CLIInstanceCheck {
|
||||
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) {
|
||||
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.
|
||||
_ = 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
|
||||
MainActor.assumeIsolated {
|
||||
let model = PlayerModel.shared
|
||||
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 }
|
||||
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 {
|
||||
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
|
||||
}
|
||||
@@ -101,6 +176,10 @@ private enum CLIInstanceCheck {
|
||||
"showBufferInfo": model.showsBufferInfo,
|
||||
"automaticQuality": model.automaticYouTubeQuality,
|
||||
"snapToEdges": model.compactWindow.snappingEnabled,
|
||||
"requestID": requestID,
|
||||
"editorText": editorText,
|
||||
"compact": model.isCompact,
|
||||
"onTop": model.isOnTop,
|
||||
]
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: snapshot)
|
||||
|
||||
+142
-16
@@ -11,18 +11,96 @@ struct ConfigurationChecks {
|
||||
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 {
|
||||
_ = try PlayerConfiguration.load(from: url)
|
||||
} catch let caught {
|
||||
let diagnostic = caught.localizedDescription + " " + String(describing: caught)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
try checkBindings()
|
||||
let files = FileManager.default
|
||||
let root = files.temporaryDirectory.appendingPathComponent("terebi.configuration-check.\(UUID().uuidString)")
|
||||
try files.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
@@ -47,16 +125,18 @@ struct ConfigurationChecks {
|
||||
|
||||
try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let original = Data("""
|
||||
# Root keys can be quoted, with comments and whitespace.
|
||||
"show_buffer_info" = true # session display default
|
||||
# Root and nested keys can be quoted, with comments and whitespace.
|
||||
'automatic_quality' = true
|
||||
snap_to_edges = false
|
||||
[ui]
|
||||
"buffer_info" = true # session display default
|
||||
compact_snap_to_edges = false
|
||||
""".utf8)
|
||||
try original.write(to: config)
|
||||
try files.setAttributes([.posixPermissions: 0o444], ofItemAtPath: config.path)
|
||||
let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false)
|
||||
let environment = ["XDG_CONFIG_HOME": xdg.path]
|
||||
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)
|
||||
try require(session == configured, "application startup automatically loads the XDG config")
|
||||
session.showBufferInfo = false
|
||||
@@ -76,21 +156,67 @@ struct ConfigurationChecks {
|
||||
try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", partial.absoluteString],
|
||||
environment: environment, homeDirectory: home)
|
||||
== 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)] = [
|
||||
("syntax", "show_buffer_info = [\n"),
|
||||
("duplicate", "show_buffer_info = true\n\"show_buffer_info\" = false\n"),
|
||||
("unknown", "unknown_setting = true\n"),
|
||||
("string", "show_buffer_info = \"true\"\n"),
|
||||
("integer", "automatic_quality = 1\n"),
|
||||
("array", "snap_to_edges = [true]\n"),
|
||||
("table", "[show_buffer_info]\nenabled = true\n")
|
||||
let invalid: [(String, String, [String])] = [
|
||||
("syntax", "automatic_quality = [\n", ["automatic_quality"]),
|
||||
("duplicate", "automatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
|
||||
("unknown", "unknown_setting = true\n", ["unknown_setting"]),
|
||||
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
|
||||
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
|
||||
("ui-unknown", "[ui]\nunknown_setting = true\n", ["ui.unknown_setting"]),
|
||||
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
|
||||
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
||||
("integer", "automatic_quality = 1\n", []),
|
||||
("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 bytes = Data(text.utf8)
|
||||
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")
|
||||
}
|
||||
let invalidUTF8 = root.appendingPathComponent("invalid-utf8.toml")
|
||||
@@ -112,7 +238,7 @@ struct ConfigurationChecks {
|
||||
let badXDG = root.appendingPathComponent("bad-xdg")
|
||||
let badConfig = badXDG.appendingPathComponent("terebi/config.toml")
|
||||
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 require(try PlayerConfiguration.loadForApplication(arguments: ["--no-config"],
|
||||
environment: ["XDG_CONFIG_HOME": badXDG.path], homeDirectory: home) == defaults,
|
||||
|
||||
@@ -62,6 +62,16 @@ struct SystemPlaybackChecks {
|
||||
require(model.state.position == pausedPosition, "repeated pause preserves position")
|
||||
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
|
||||
"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
|
||||
var nonseekableState = seekableState
|
||||
|
||||
@@ -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)
|
||||
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
|
||||
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
|
||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
|
||||
CONFIG_BUILD := .build/configuration
|
||||
@@ -46,12 +46,12 @@ $(BUILD_DIR):
|
||||
Package.resolved: Package.swift
|
||||
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
|
||||
|
||||
$(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
|
||||
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
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
show_buffer_info = 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| `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.
|
||||
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.
|
||||
|
||||
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/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/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/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.
|
||||
@@ -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/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/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.
|
||||
|
||||
## 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-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.
|
||||
|
||||
@@ -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-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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ final class SystemPlaybackControls {
|
||||
switch action {
|
||||
case .play: accepted = model.setPlayback(playing: true)
|
||||
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)
|
||||
}
|
||||
return accepted ? .success : .commandFailed
|
||||
|
||||
+197
-139
@@ -27,6 +27,24 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var isFullScreen = false
|
||||
@Published private(set) var isOnTop = false
|
||||
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 canToggleCompact: Bool {
|
||||
isCompact || compactWindow.isTransitioning ||
|
||||
@@ -66,6 +84,7 @@ final class PlayerModel: ObservableObject {
|
||||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
keyBindings = configuration.keys
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
showsBufferInfo = configuration.showBufferInfo
|
||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
|
||||
@@ -80,7 +99,14 @@ final class PlayerModel: ObservableObject {
|
||||
videoView = nil
|
||||
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
|
||||
MainActor.assumeIsolated { self?.poll() }
|
||||
}
|
||||
@@ -187,7 +213,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
let current = player_get_state(handle)
|
||||
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,
|
||||
@@ -200,6 +226,10 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
if !automatic {
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
}
|
||||
let request = UUID()
|
||||
youtubeRequest = request
|
||||
resolvingYouTube = true
|
||||
@@ -296,6 +326,8 @@ final class PlayerModel: ObservableObject {
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
|
||||
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
|
||||
guard let handle else { return }
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||
pendingRevision = revision
|
||||
fileName = title
|
||||
@@ -347,7 +379,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
setPlayback(playing: state.status != PLAYER_PLAYING)
|
||||
perform(playbackAction)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -355,7 +387,18 @@ final class PlayerModel: ObservableObject {
|
||||
guard let handle, canTogglePlayback else { return false }
|
||||
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
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)
|
||||
if accepted {
|
||||
playbackIntent = playing
|
||||
playbackInFlight = (playing, revision)
|
||||
}
|
||||
commandError = accepted ? nil : "Could not queue playback. Please try again."
|
||||
revealControls()
|
||||
return accepted
|
||||
@@ -382,6 +425,9 @@ final class PlayerModel: ObservableObject {
|
||||
commandError = "Could not queue closing the file. Please try again."
|
||||
return
|
||||
}
|
||||
playbackIntent = nil
|
||||
playbackInFlight = nil
|
||||
compactIntent = nil
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
@@ -420,12 +466,18 @@ final class PlayerModel: ObservableObject {
|
||||
return accepted
|
||||
}
|
||||
|
||||
func stepFrame(_ direction: Int32) {
|
||||
guard canStepFrame, let handle else { return }
|
||||
@discardableResult
|
||||
func stepFrame(_ direction: Int32) -> Bool {
|
||||
guard canStepFrame, let handle else { return false }
|
||||
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."
|
||||
revealControls()
|
||||
return accepted
|
||||
}
|
||||
|
||||
func setVolume(_ value: Double) {
|
||||
@@ -438,8 +490,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggleMute() {
|
||||
guard canSetVolume else { return }
|
||||
setVolume(volume == 0 ? lastAudibleVolume : 0)
|
||||
perform(muteAction)
|
||||
}
|
||||
|
||||
func attachWindow(_ window: NSWindow) {
|
||||
@@ -455,6 +506,7 @@ final class PlayerModel: ObservableObject {
|
||||
func updateWindowState() {
|
||||
guard let window else { return }
|
||||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||||
if !compactWindow.isTransitioning && fullScreenIntent == isFullScreen { fullScreenIntent = nil }
|
||||
windowActive = window.isKeyWindow
|
||||
if !compactWindow.isTransitioning {
|
||||
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
|
||||
@@ -464,25 +516,119 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggleFullScreen() {
|
||||
guard !compactWindow.isTransitioning else { return }
|
||||
if isCompact { compactWindow.restore(resumeFullScreen: false) }
|
||||
window?.toggleFullScreen(nil)
|
||||
revealControls()
|
||||
perform(fullscreenAction)
|
||||
}
|
||||
|
||||
func toggleCompact() {
|
||||
guard canToggleCompact else { return }
|
||||
perform(compactAction)
|
||||
}
|
||||
|
||||
func toggleOnTop() {
|
||||
perform(onTopAction)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func toggleOnTop() {
|
||||
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return }
|
||||
isOnTop.toggle()
|
||||
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) {
|
||||
@@ -594,6 +740,17 @@ final class PlayerModel: ObservableObject {
|
||||
defer { systemPlayback?.update() }
|
||||
let statusChanged = state.status != next.status
|
||||
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 ||
|
||||
(!isBusy && !next.has_video) {
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
@@ -633,7 +790,7 @@ final class PlayerModel: ObservableObject {
|
||||
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
|
||||
let sample = AutomaticQuality.Sample(
|
||||
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,
|
||||
buffering: state.buffering, position: state.position, duration: state.duration,
|
||||
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
|
||||
@@ -906,52 +1063,22 @@ private struct PlayerContent: View {
|
||||
|
||||
private var actionsMenu: some View {
|
||||
Menu {
|
||||
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
|
||||
Button("Close File", action: model.closeFile).disabled(!model.canClose)
|
||||
if !model.youtubeHeights.isEmpty {
|
||||
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
|
||||
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
|
||||
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
|
||||
Divider()
|
||||
Menu("Quality") {
|
||||
Toggle("Automatic Quality (up to 1080p)", isOn: Binding(
|
||||
get: { model.automaticYouTubeQuality },
|
||||
set: { model.setAutomaticYouTubeQuality($0) }))
|
||||
PlayerQualityMenu(model: model)
|
||||
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")
|
||||
}
|
||||
PlayerActionButton(model: model, action: model.bufferInfoAction,
|
||||
title: model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info")
|
||||
Divider()
|
||||
Toggle("Show Buffer Info", isOn: Binding(
|
||||
get: { model.showsBufferInfo },
|
||||
set: { model.setShowsBufferInfo($0) }))
|
||||
PlayerActionButton(model: model, action: model.compactAction,
|
||||
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()
|
||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||
.disabled(!model.canToggleCompact)
|
||||
Toggle("Snap to Screen Edges", isOn: Binding(
|
||||
get: { model.compactWindow.snappingEnabled },
|
||||
set: { model.compactWindow.snappingEnabled = $0 }))
|
||||
Divider()
|
||||
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
|
||||
PlayerActionButton(model: model, action: model.onTopAction,
|
||||
title: model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top")
|
||||
} label: {
|
||||
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 {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 16) {
|
||||
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
|
||||
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
||||
iconButton(model.effectivePlaying ? "pause.fill" : "play.fill",
|
||||
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
|
||||
.disabled(!model.canTogglePlayback)
|
||||
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
||||
.disabled(model.compactWindow.isTransitioning)
|
||||
@@ -1005,13 +1132,13 @@ private struct PlayerContent: View {
|
||||
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||||
.disabled(!model.canSeek)
|
||||
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))
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play")
|
||||
.help(model.state.status == PLAYER_PLAYING ? "Pause (K / Space)" : "Play (K / Space)")
|
||||
.accessibilityLabel(model.effectivePlaying ? "Pause" : "Play")
|
||||
.help(model.effectivePlaying ? "Pause" : "Play")
|
||||
.disabled(!model.canTogglePlayback)
|
||||
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||||
.disabled(!model.canSeek)
|
||||
@@ -1231,34 +1358,8 @@ private struct WindowChrome: NSViewRepresentable {
|
||||
required init?(coder: NSCoder) { nil }
|
||||
|
||||
func handleKeyEvent(_ event: NSEvent) -> Bool {
|
||||
guard event.type == .keyDown, let window = configuredWindow, event.window === window,
|
||||
!(window.firstResponder is NSTextView),
|
||||
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
|
||||
guard let window = configuredWindow else { return false }
|
||||
return model.handleKeyEvent(event, in: window)
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
@@ -1396,54 +1497,11 @@ struct TerebiApp: App {
|
||||
var body: some Scene {
|
||||
Window("\(model.fileName ?? "terebi")", id: "terebi") {
|
||||
PlayerContent(model: model)
|
||||
.focusedSceneObject(model)
|
||||
}
|
||||
.defaultSize(width: 900, height: 560)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.commands {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.commands { PlayerCommands() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
public struct KeyModifiers: OptionSet, Hashable, Sendable {
|
||||
public let rawValue: UInt8
|
||||
public init(rawValue: UInt8) { self.rawValue = rawValue }
|
||||
public static let command = Self(rawValue: 1 << 0)
|
||||
public static let control = Self(rawValue: 1 << 1)
|
||||
public static let option = Self(rawValue: 1 << 2)
|
||||
public static let shift = Self(rawValue: 1 << 3)
|
||||
}
|
||||
|
||||
public struct KeyChord: Hashable, Sendable {
|
||||
public let key: String
|
||||
public let modifiers: KeyModifiers
|
||||
|
||||
public init(key: String, modifiers: KeyModifiers) {
|
||||
self.key = key
|
||||
self.modifiers = modifiers
|
||||
}
|
||||
|
||||
public init(_ text: String) throws {
|
||||
let parts = text.lowercased().split(separator: "+", omittingEmptySubsequences: false)
|
||||
func invalid(_ reason: String) -> BindingError {
|
||||
BindingError(message: "Invalid chord '\(text)': \(reason)")
|
||||
}
|
||||
guard let last = parts.last, !last.isEmpty, parts.allSatisfy({ !$0.isEmpty }) else {
|
||||
throw invalid("empty key or modifier; use 'plus' for the + key.")
|
||||
}
|
||||
var modifiers: KeyModifiers = []
|
||||
for part in parts.dropLast() {
|
||||
let modifier: KeyModifiers
|
||||
switch part {
|
||||
case "cmd": modifier = .command
|
||||
case "ctrl": modifier = .control
|
||||
case "alt", "option": modifier = .option
|
||||
case "shift": modifier = .shift
|
||||
default: throw invalid("unknown modifier '\(part)'.")
|
||||
}
|
||||
guard !modifiers.contains(modifier) else { throw invalid("duplicate modifier '\(part)'.") }
|
||||
modifiers.insert(modifier)
|
||||
}
|
||||
let key = String(last)
|
||||
let namedKeys: Set<String> = ["space", "escape", "return", "tab", "backspace", "delete",
|
||||
"left", "right", "up", "down", "home", "end", "pageup", "pagedown", "plus"]
|
||||
let functionKey: Bool
|
||||
if key.hasPrefix("f"), let number = Int(key.dropFirst()) {
|
||||
functionKey = (1...35).contains(number) && key == "f\(number)"
|
||||
} else {
|
||||
functionKey = false
|
||||
}
|
||||
let printable = key.count == 1 && key != "+" && key.unicodeScalars.allSatisfy {
|
||||
!CharacterSet.controlCharacters.contains($0) && !CharacterSet.whitespacesAndNewlines.contains($0)
|
||||
}
|
||||
guard namedKeys.contains(key) || functionKey || printable else { throw invalid("unknown key '\(key)'.") }
|
||||
guard !(key == "tab" && modifiers.contains(.command)) else {
|
||||
throw invalid("unsupported OS-reserved cmd+tab shortcut.")
|
||||
}
|
||||
self.init(key: key == "plus" ? "+" : key, modifiers: modifiers)
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
var parts: [String] = []
|
||||
if modifiers.contains(.control) { parts.append("ctrl") }
|
||||
if modifiers.contains(.option) { parts.append("alt") }
|
||||
if modifiers.contains(.shift) { parts.append("shift") }
|
||||
if modifiers.contains(.command) { parts.append("cmd") }
|
||||
parts.append(key == "+" ? "plus" : key)
|
||||
return parts.joined(separator: "+")
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyBindings: Equatable, Sendable {
|
||||
private let byAction: [PlayerAction: [KeyChord]]
|
||||
private let byChord: [KeyChord: [PlayerAction]]
|
||||
|
||||
private static let builtIn: [PlayerAction: [KeyChord]] = {
|
||||
let texts: [PlayerAction: [String]] = [
|
||||
.play: ["k", "space"], .pause: ["k", "space"],
|
||||
.mute: ["m"], .unmute: ["m"], .volumeUp: ["up"], .volumeDown: ["down"],
|
||||
.seekForward5s: ["right"], .seekBackward5s: ["left"],
|
||||
.seekForward10s: ["l"], .seekBackward10s: ["j"],
|
||||
.stepFrameForward: ["."], .stepFrameBackward: [","],
|
||||
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
|
||||
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
|
||||
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
|
||||
.openFile: ["o", "cmd+o"], .openYoutube: ["cmd+shift+o"],
|
||||
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
|
||||
]
|
||||
return texts.mapValues { $0.map { try! KeyChord($0) } }
|
||||
}()
|
||||
private static let defaults = try! KeyBindings(overrides: [:])
|
||||
|
||||
public init() { self = Self.defaults }
|
||||
|
||||
public init(overrides: [String: [String]]) throws {
|
||||
var bindings = Self.builtIn
|
||||
for name in overrides.keys.sorted() {
|
||||
guard let action = PlayerAction(name: name) else {
|
||||
throw BindingError(message: "Unknown action 'keys.\(name)'.")
|
||||
}
|
||||
var chords: [KeyChord] = []
|
||||
var seen: Set<KeyChord> = []
|
||||
for text in overrides[name]! {
|
||||
let chord: KeyChord
|
||||
do { chord = try KeyChord(text) }
|
||||
catch { throw BindingError(message: "keys.\(name): \(error.localizedDescription)") }
|
||||
guard seen.insert(chord).inserted else {
|
||||
throw BindingError(message: "keys.\(name): duplicate chord '\(text)' (\(chord.description)).")
|
||||
}
|
||||
chords.append(chord)
|
||||
}
|
||||
if chords.isEmpty { bindings.removeValue(forKey: action) }
|
||||
else { bindings[action] = chords }
|
||||
}
|
||||
var lookup: [KeyChord: [PlayerAction]] = [:]
|
||||
for action in bindings.keys.sorted(by: { $0.name < $1.name }) {
|
||||
for chord in bindings[action]! {
|
||||
if let existing = lookup[chord]?.first, existing.inverse != action {
|
||||
throw BindingError(message: "Chord '\(chord.description)' conflicts between keys.\(existing.name) and keys.\(action.name).")
|
||||
}
|
||||
lookup[chord, default: []].append(action)
|
||||
}
|
||||
}
|
||||
byAction = bindings
|
||||
byChord = lookup
|
||||
}
|
||||
|
||||
public func shortcuts(for action: PlayerAction) -> [KeyChord] { byAction[action] ?? [] }
|
||||
public func actions(for chord: KeyChord) -> [PlayerAction] { byChord[chord] ?? [] }
|
||||
}
|
||||
|
||||
struct BindingError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
public enum PlayerAction: Hashable, Sendable {
|
||||
case play, pause, mute, unmute, volumeUp, volumeDown
|
||||
case seekForward5s, seekBackward5s, seekForward10s, seekBackward10s
|
||||
case stepFrameForward, stepFrameBackward
|
||||
case enterFullscreen, exitFullscreen, enterCompact, exitCompact
|
||||
case enableAlwaysOnTop, disableAlwaysOnTop
|
||||
case openFile, openYoutube, closeFile, quit
|
||||
case showBufferInfo, hideBufferInfo
|
||||
case enableCompactSnapping, disableCompactSnapping
|
||||
case enableAutomaticQuality, disableAutomaticQuality
|
||||
case qualityDefault, quality(Int)
|
||||
|
||||
static let namedActions: [(String, PlayerAction)] = [
|
||||
("play", .play), ("pause", .pause), ("mute", .mute), ("unmute", .unmute),
|
||||
("volume_up", .volumeUp), ("volume_down", .volumeDown),
|
||||
("seek_forward_5s", .seekForward5s), ("seek_backward_5s", .seekBackward5s),
|
||||
("seek_forward_10s", .seekForward10s), ("seek_backward_10s", .seekBackward10s),
|
||||
("step_frame_forward", .stepFrameForward), ("step_frame_backward", .stepFrameBackward),
|
||||
("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen),
|
||||
("enter_compact", .enterCompact), ("exit_compact", .exitCompact),
|
||||
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
|
||||
("open_file", .openFile), ("open_youtube", .openYoutube), ("close_file", .closeFile), ("quit", .quit),
|
||||
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
|
||||
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
|
||||
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
|
||||
("quality_default", .qualityDefault)
|
||||
]
|
||||
private static let actionsByName = Dictionary(uniqueKeysWithValues: namedActions)
|
||||
private static let namesByAction = Dictionary(uniqueKeysWithValues: namedActions.map { ($0.1, $0.0) })
|
||||
|
||||
public init?(name: String) {
|
||||
if let action = Self.actionsByName[name] {
|
||||
self = action
|
||||
} else if name.hasPrefix("quality_"), name.hasSuffix("p") {
|
||||
let digits = name.dropFirst("quality_".count).dropLast()
|
||||
guard !digits.isEmpty, digits.allSatisfy({ $0 >= "0" && $0 <= "9" }),
|
||||
let height = Int(digits), height > 0, String(height) == digits else { return nil }
|
||||
self = .quality(height)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
if case .quality(let height) = self { return "quality_\(height)p" }
|
||||
return Self.namesByAction[self]!
|
||||
}
|
||||
|
||||
public var inverse: PlayerAction? {
|
||||
switch self {
|
||||
case .play: return .pause
|
||||
case .pause: return .play
|
||||
case .mute: return .unmute
|
||||
case .unmute: return .mute
|
||||
case .enterFullscreen: return .exitFullscreen
|
||||
case .exitFullscreen: return .enterFullscreen
|
||||
case .enterCompact: return .exitCompact
|
||||
case .exitCompact: return .enterCompact
|
||||
case .enableAlwaysOnTop: return .disableAlwaysOnTop
|
||||
case .disableAlwaysOnTop: return .enableAlwaysOnTop
|
||||
case .showBufferInfo: return .hideBufferInfo
|
||||
case .hideBufferInfo: return .showBufferInfo
|
||||
case .enableCompactSnapping: return .disableCompactSnapping
|
||||
case .disableCompactSnapping: return .enableCompactSnapping
|
||||
case .enableAutomaticQuality: return .disableAutomaticQuality
|
||||
case .disableAutomaticQuality: return .enableAutomaticQuality
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var allowsRepeat: Bool {
|
||||
switch self {
|
||||
case .volumeUp, .volumeDown, .seekForward5s, .seekBackward5s,
|
||||
.seekForward10s, .seekBackward10s, .stepFrameForward, .stepFrameBackward: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var showBufferInfo: Bool
|
||||
public var automaticQuality: Bool
|
||||
public var snapToEdges: Bool
|
||||
public var keys: KeyBindings
|
||||
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true) {
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
||||
keys: KeyBindings = KeyBindings()) {
|
||||
self.showBufferInfo = showBufferInfo
|
||||
self.automaticQuality = automaticQuality
|
||||
self.snapToEdges = snapToEdges
|
||||
self.keys = keys
|
||||
}
|
||||
|
||||
public static func fileURL(environment: [String: String], homeDirectory: URL) -> URL {
|
||||
@@ -49,34 +52,123 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
throw ConfigurationError(message: "\(url.path): \(error.localizedDescription)")
|
||||
}
|
||||
do {
|
||||
let values = try TOMLDecoder(isLenient: false).decode([String: Bool].self, from: data)
|
||||
for key in values.keys {
|
||||
switch key {
|
||||
case "show_buffer_info", "automatic_quality", "snap_to_edges": break
|
||||
default: throw ConfigurationError(message: "Unknown setting '\(key)'.")
|
||||
}
|
||||
}
|
||||
var configuration = Self()
|
||||
configuration.showBufferInfo = values["show_buffer_info"] ?? configuration.showBufferInfo
|
||||
configuration.automaticQuality = values["automatic_quality"] ?? configuration.automaticQuality
|
||||
configuration.snapToEdges = values["snap_to_edges"] ?? configuration.snapToEdges
|
||||
return configuration
|
||||
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
|
||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||
automaticQuality: values.automaticQuality ?? false,
|
||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
|
||||
} catch {
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error))")
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
|
||||
}
|
||||
}
|
||||
|
||||
private static func diagnostic(for error: Error) -> String {
|
||||
private static func diagnostic(for error: Error, data: Data) -> String {
|
||||
let context: DecodingError.Context
|
||||
var missingKey: String?
|
||||
switch error {
|
||||
case DecodingError.dataCorrupted(let value), DecodingError.typeMismatch(_, let value),
|
||||
DecodingError.valueNotFound(_, let value), DecodingError.keyNotFound(_, let value): context = value
|
||||
DecodingError.valueNotFound(_, let value): context = value
|
||||
case DecodingError.keyNotFound(let key, let value):
|
||||
context = value
|
||||
missingKey = key.stringValue
|
||||
case let error as ConfigurationError: return error.message
|
||||
case let error as BindingError: return error.message
|
||||
default: return String(describing: error)
|
||||
}
|
||||
if let underlying = context.underlyingError { return String(describing: underlying) }
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return path.isEmpty ? context.debugDescription : "\(path): \(context.debugDescription)"
|
||||
var path = context.codingPath.map(\.stringValue)
|
||||
if let missingKey, path.last != missingKey { path.append(missingKey) }
|
||||
let detail = context.underlyingError.map { String(describing: $0) } ?? context.debugDescription
|
||||
if !path.isEmpty { return "\(path.joined(separator: ".")): \(detail)" }
|
||||
// TOML syntax failures supply a line number, but not always the setting.
|
||||
if let text = String(data: data, encoding: .utf8),
|
||||
let marker = detail.range(of: "(Line "),
|
||||
let end = detail[marker.upperBound...].firstIndex(of: ")"),
|
||||
let line = Int(detail[marker.upperBound..<end]), line > 0 {
|
||||
let lines = text.components(separatedBy: "\n")
|
||||
let index = min(line - 1, lines.count - 1)
|
||||
let source = lines[...index].last(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
|
||||
if let source { return "\(detail) [\(source)]" }
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
// An enum CodingKey would silently hide unknown TOML keys from allKeys.
|
||||
private struct SettingKey: CodingKey {
|
||||
let stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
init(_ value: String) { stringValue = value }
|
||||
init?(stringValue: String) { self.init(stringValue) }
|
||||
init?(intValue: Int) { return nil }
|
||||
}
|
||||
|
||||
private static func validate(_ container: KeyedDecodingContainer<SettingKey>, allowed: Set<String>) throws {
|
||||
for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) {
|
||||
guard allowed.contains(key.stringValue) else {
|
||||
let path = (container.codingPath + [key]).map(\.stringValue).joined(separator: ".")
|
||||
throw ConfigurationError(message: "Unknown setting '\(path)'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FileConfiguration: Decodable {
|
||||
let automaticQuality: Bool?
|
||||
let ui: UIConfiguration?
|
||||
let keys: KeysConfiguration?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: SettingKey.self)
|
||||
try validate(container, allowed: ["automatic_quality", "ui", "keys"])
|
||||
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
||||
if container.contains(SettingKey("ui")) {
|
||||
ui = try UIConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("ui")))
|
||||
} else { ui = nil }
|
||||
if container.contains(SettingKey("keys")) {
|
||||
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
|
||||
} else { keys = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private struct UIConfiguration {
|
||||
let bufferInfo: Bool?
|
||||
let compactSnapToEdges: Bool?
|
||||
|
||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges"])
|
||||
bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info"))
|
||||
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeysConfiguration {
|
||||
let overrides: [String: [String]]
|
||||
|
||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||
var values: [String: [String]] = [:]
|
||||
for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) {
|
||||
guard PlayerAction(name: key.stringValue) != nil else {
|
||||
throw ConfigurationError(message: "Unknown action 'keys.\(key.stringValue)'.")
|
||||
}
|
||||
values[key.stringValue] = try container.decode(ShortcutList.self, forKey: key).chords
|
||||
}
|
||||
overrides = values
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShortcutList: Decodable {
|
||||
let chords: [String]
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
if let chord = try? String(from: decoder) {
|
||||
chords = [chord]
|
||||
} else {
|
||||
do { chords = try [String](from: decoder) }
|
||||
catch {
|
||||
throw DecodingError.typeMismatch([String].self,
|
||||
DecodingError.Context(codingPath: decoder.codingPath,
|
||||
debugDescription: "Expected a shortcut string or an array of shortcut strings."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConfigurationError: LocalizedError {
|
||||
|
||||
Reference in New Issue
Block a user