Compare commits

..

10 Commits

34 changed files with 4616 additions and 546 deletions
+20
View File
@@ -0,0 +1,20 @@
# Terebi playback
Terebi plays local media and YouTube videos. These terms distinguish a playback request from the state the player has actually reached.
## Language
**Playback transition**:
A requested change of media, playback state, position, or quality that may still be awaiting completion. Acceptance of a request does not mean playback has reached the requested state.
**Playback intent**:
The user's latest requested playing or paused state, which can differ from the state playback has acknowledged.
**Acknowledged position**:
The playback position confirmed by the player after any pending seek or restoration has completed. A seek target or slider preview is not an acknowledged position.
**Restoration**:
Resuming a video's saved position, or preserving position and playing or paused state while changing its quality.
**Media identity**:
The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL.
+448
View File
@@ -1,14 +1,461 @@
import json
import os
import math
from pathlib import Path
import plistlib
import selectors
import shutil
import shlex
import subprocess
import sys
import tempfile
import time
import uuid
from streaming import StreamingServer
def check_cli_instances(root, app, fixtures):
cli = root / "terebi"
shutil.copy2(app_source.parent / "terebi", cli)
extractor = shutil.which("yt-dlp")
runtime = shutil.which("deno") or shutil.which("node")
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
reported_urls = [f"https://www.youtube.com/watch?v=progress00{index}" for index in range(1, 5)]
config = root / "xdg/terebi/config.toml"
environment = dict(os.environ, XDG_CONFIG_HOME=str(config.parent.parent))
def invoke(arguments, expected_code=0):
result = subprocess.run([str(cli), *arguments], capture_output=True, text=True, timeout=20,
env=environment)
if result.returncode != expected_code:
raise RuntimeError(f"CLI {arguments} returned {result.returncode}, expected {expected_code}; {result.stdout}{result.stderr}")
return result
def process_diagnostics(pid):
try:
return (root / f"stderr-{pid}.log").read_text(errors="replace")
except FileNotFoundError:
return "<not published>"
def wait_states(count, ready, message, allow_errors=()):
deadline = time.monotonic() + 30
states = {}
while time.monotonic() < deadline:
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
for path in root.glob("state-*.json")}
for pid in states:
try:
os.kill(pid, 0)
except ProcessLookupError:
raise RuntimeError(f"{message}: GUI PID {pid} exited unexpectedly; "
f"last state: {states[pid]}; diagnostics: {process_diagnostics(pid)}")
complete = len(states) == count and all(
set(state["pids"]) == set(states) and state["windows"] == 1 for state in states.values())
if any(state["error"] for pid, state in states.items() if pid not in allow_errors):
raise RuntimeError(f"{message}: native playback error: {states}")
if complete and ready(states):
return states
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
raise RuntimeError(f"{message}: native state deadline: {states}; "
f"process diagnostics: { {pid: process_diagnostics(pid) for pid in states} }")
def settings(state):
return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"])
def write_request(pid, events=None, media=None, action=None, **arguments):
request = {"id": uuid.uuid4().hex, "events": events or [], **arguments}
if media is not None:
request["media"] = str(media)
if action is not None:
request["action"] = action
path = root / f"keys-{pid}.json"
pending = path.with_suffix(".tmp")
pending.write_text(json.dumps(request))
pending.replace(path)
return request["id"]
def input_request(pid, count, ready, message, events=None, media=None, action=None,
allow_error=False, **arguments):
request_id = write_request(pid, events=events, media=media, action=action, **arguments)
return wait_states(count, lambda states: states[pid]["requestID"] == request_id and ready(states[pid]),
f"{message} (expected request {request_id})", allow_errors=(pid,) if allow_error else ())
def read_reports(path, allowed):
try:
lines = path.read_bytes().split(b"\n")[:-1] # The writer may still be filling the trailing line.
except FileNotFoundError:
return []
records = [json.loads(line) for line in lines if line]
fields = {"version", "event", "media_url", "position_seconds", "duration_seconds", "completion_eligible"}
for record in records:
duration = record.get("duration_seconds")
position = record.get("position_seconds")
valid = (set(record) == fields and record["version"] == 1
and record["event"] in ("progress", "ended", "closed")
and record["media_url"] in allowed
and type(position) in (int, float) and math.isfinite(position) and position >= 0
and (duration is None or (type(duration) in (int, float)
and math.isfinite(duration) and duration > 0))
and type(record["completion_eligible"]) is bool
and (not record["completion_eligible"] or duration is not None))
if not valid:
raise RuntimeError(f"invalid or mixed native progress stream {path}: {record}")
return records
def wait_report(path, allowed, ready, message):
deadline = time.monotonic() + 30
records = []
while time.monotonic() < deadline:
records = read_reports(path, allowed)
if ready(records):
return records
time.sleep(0.02)
raise RuntimeError(f"{message}: native report deadline {path}: {records}")
def at_position(state, position):
return (state["status"] == 2 and not state["busy"] and not state["seekPending"]
and abs(state["position"] - position) < 0.5)
def progress_at(record, url, position):
return (record["event"] == "progress" and record["media_url"] == url
and abs(record["position_seconds"] - position) < 0.5)
def watch_fact(record):
return (record["completion_eligible"] and record["duration_seconds"] is not None
and record["position_seconds"] / record["duration_seconds"] >= 0.95)
def quit_instance(pid, count):
write_request(pid, action="quit")
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
survivors = [json.loads(path.read_text()) for path in root.glob("state-*.json")
if path.stem != f"state-{pid}"]
if survivors and all(pid not in state["pids"] for state in survivors):
(root / f"state-{pid}.json").unlink()
(root / f"keys-{pid}.json").unlink()
return wait_states(count - 1, lambda _: True, "normal quit unregisters only the targeted GUI")
time.sleep(0.02)
raise RuntimeError(f"normal quit did not unregister GUI PID {pid}")
with StreamingServer(fixtures) as server:
if extractor and runtime:
choices = []
for index, url in enumerate([canonical, *reported_urls[:3]]):
metadata = root / f"youtube-{index}.json"
metadata.write_text(json.dumps({
"id": url.split("v=")[1],
"title": "Independent YouTube fixture" if index == 0 else f"Reported YouTube fixture {index}",
"duration": 120, "is_live": False, "webpage_url": url, "extractor": "youtube",
"extractor_key": "Youtube", "formats": [{
"format_id": str(height), "height": height, "width": height * 16 // 9,
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
"http_headers": {"X-Terebi-Fixture": "combined"}} for height in (180, 360)]}))
choices.append(f" {shlex.quote(url)}) info={shlex.quote(str(metadata))} ;;")
wrapper = root / "yt-dlp"
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
'case "${args[${#args[@]}-1]}" in\n' + "\n".join(choices)
+ '\n *) echo "Fixture extraction deliberately failed" >&2; exit 1 ;;\nesac\nexec '
+ shlex.quote(extractor) + ' --load-info-json "$info"'
+ ' "${args[@]:0:${#args[@]}-2}"\n')
wrapper.chmod(0o755)
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
movie = fixtures / "cache.mp4"
audio = root / "audio with spaces.wav"
shutil.copy2(fixtures / "audio.wav", audio)
invoke([str(movie)])
first = wait_states(1, lambda states: all(state["media"] == movie.name and state["status"] == 3
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
"cold default CLI opens one actual player window")
first_pid = next(iter(first))
if settings(first[first_pid]) != (False, False, True) or config.parent.exists():
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
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 = ('[playback]\nautomatic_quality = true\n[ui]\n"buffer_info" = true # optional display\n'
'compact_snap_to_edges = false\n[keys]\nplay = ["cmd+8", "cmd+x", "x"]\n'
'pause = ["cmd+8", "cmd+x", "x"]\nshow_buffer_info = "b"\nhide_buffer_info = "b"\n')
config.write_text(config_text)
invoke(["--new-instance"])
second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0
and settings(state) == (True, True, False)
for pid, state in states.items() if pid != first_pid),
"empty independent CLI creates exactly one extra player")
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"]
for pid, state in states.items() if pid not in second),
"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("[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}")
invoke(["--no-config"])
unchanged = wait_states(3, lambda _: True, "bypass reuses an existing process without resetting its settings")
if any(settings(unchanged[pid]) != settings(third[pid]) for pid in third):
raise RuntimeError(f"warm bypass changed an existing session: {third}; {unchanged}")
invoke(["--no-config", "--new-instance", str(movie)])
bypass = wait_states(4, lambda states: any(state["media"] == movie.name and state["status"] == 3
and settings(state) == (False, False, True)
for pid, state in states.items() if pid not in third),
"fresh bypass skips invalid config and plays with built-in settings")
invoke(["--no-config", "--new-instance", "--help"])
wait_states(4, lambda _: True, "help skips invalid config without starting another process")
config.write_text(config_text)
count = 4
if extractor and runtime:
invoke(["--new-instance", "--url", canonical])
fourth = wait_states(5, lambda states: any(
state["media"] == "Independent YouTube fixture" and state["status"] == 3
and state["position"] > 0 and state["hasAudio"] and state["hasVideo"]
and settings(state) == (True, True, False)
for pid, state in states.items() if pid not in bypass),
"independent YouTube playback reaches only one new registered process")
if fourth[first_pid]["media"] != movie.name or fourth[first_pid]["status"] != 3 or fourth[empty_pid]["media"]:
raise RuntimeError(f"independent YouTube launch disturbed existing playback: {fourth}")
count = 5
else:
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
existing_report = root / "existing progress.jsonl"
sentinel = b"existing destination must remain byte-for-byte unchanged\n"
existing_report.write_bytes(sentinel)
rejection = invoke(["--new-instance", "--url", reported_urls[0],
"--progress-file", str(existing_report)], expected_code=73)
if str(existing_report) not in rejection.stderr or existing_report.read_bytes() != sentinel:
raise RuntimeError(f"existing report rejection lost its path or altered data: {rejection.stderr}")
missing_target = root / "missing progress target.jsonl"
dangling_report = root / "dangling progress.jsonl"
dangling_report.symlink_to(missing_target)
rejection = invoke(["--new-instance", "--url", reported_urls[0],
"--progress-file", str(dangling_report)], expected_code=73)
if str(dangling_report) not in rejection.stderr or not dangling_report.is_symlink() or missing_target.exists():
raise RuntimeError(f"dangling report rejection altered its destination: {rejection.stderr}")
originals = wait_states(count, lambda _: True, "report preflight failures never launch a GUI")
if extractor and runtime:
report_paths = [root / f"progress session {index} {uuid.uuid4().hex}.jsonl" for index in (1, 2)]
reporting_pids = []
allowed_urls = ({reported_urls[0], reported_urls[2]}, {reported_urls[1]})
states = originals
for index, path in enumerate(report_paths):
previous_pids = set(states)
# Deliberately put the space-containing absolute report path after --url.
invoke(["--new-instance", "--url", reported_urls[index], "--progress-file", str(path)])
states = wait_states(count + index + 1, lambda snapshots: any(
pid not in previous_pids and state["media"] == f"Reported YouTube fixture {index + 1}"
and state["status"] == 3 and state["position"] > 0
and state["hasVideo"] and state["canSeek"] and not state["seekPending"]
for pid, state in snapshots.items()),
"reporting CLI returned before actual registered GUI playback observations")
pid = next(pid for pid in states if pid not in previous_pids)
reporting_pids.append(pid)
wait_report(path, allowed_urls[index], lambda records: any(
record["event"] == "progress" and record["media_url"] == reported_urls[index]
and record["completion_eligible"] and record["duration_seconds"] > 0
for record in records), "registered GUI emits an initial actual decoded report")
total = count + 2
first_reporter, second_reporter = reporting_pids
first_path, second_path = report_paths
for pid, original in originals.items():
if states[pid]["media"] != original["media"] or settings(states[pid]) != settings(original):
raise RuntimeError(f"reporting launches changed an original nonreporting player: {original}; {states[pid]}")
before_pause = len(read_reports(first_path, allowed_urls[0]))
paused = input_request(first_reporter, total, lambda state: state["status"] == 2,
"reporting pause is acknowledged by the actual backend", action="pause")
paused_position = paused[first_reporter]["position"]
wait_report(first_path, allowed_urls[0], lambda records: any(
progress_at(record, reported_urls[0], paused_position) for record in records[before_pause:]),
"acknowledged playing-to-paused transition reports native position")
input_request(second_reporter, total, lambda state: state["status"] == 2,
"second reporting stream pauses independently", action="pause")
bookmark = paused[first_reporter]["duration"] * 0.4
input_request(first_reporter, total, lambda state: at_position(state, bookmark),
"paused seek reaches an acknowledged decoded position", action="seek", position=bookmark)
wait_report(first_path, allowed_urls[0], lambda records: any(
progress_at(record, reported_urls[0], bookmark) for record in records),
"seek report contains acknowledged native PTS, not just a requested target")
before_quality = len(read_reports(first_path, allowed_urls[0]))
wait_states(total, lambda snapshots: snapshots[first_reporter]["canChangeQuality"],
"manual quality control becomes available after the acknowledged seek")
input_request(first_reporter, total,
lambda state: state["youtubeHeight"] == 180 and at_position(state, bookmark),
"actual quality reopen preserves the paused acknowledged timeline",
action="quality", height=180)
quality_records = wait_report(first_path, allowed_urls[0], lambda records: any(
progress_at(record, reported_urls[0], bookmark) for record in records[before_quality:]),
"same-identity quality reopen emits its acknowledged initial record")
if any(record["event"] == "closed" for record in quality_records):
raise RuntimeError(f"quality reopen incorrectly closed its reporting identity: {quality_records}")
input_request(first_reporter, total, lambda state: state["media"] == "Reported YouTube fixture 3"
and state["status"] == 3 and not state["busy"] and not state["seekPending"],
"new YouTube identity replaces only its reporting player",
action="youtube", url=reported_urls[2])
wait_report(first_path, allowed_urls[0], lambda records: (
any(record["event"] == "closed" and record["media_url"] == reported_urls[0]
and abs(record["position_seconds"] - bookmark) < 0.5 for record in records)
and any(record["event"] == "progress" and record["media_url"] == reported_urls[2]
for record in records)), "replacement closes cached old identity and begins only the new one")
input_request(first_reporter, total, lambda state: state["status"] == 0,
"semantic close invokes actual native media close", action="close")
closed = wait_report(first_path, allowed_urls[0], lambda records: any(
record["event"] == "closed" and record["media_url"] == reported_urls[2] for record in records),
"explicit close reports the replacement identity")
before_resume = len(closed)
input_request(first_reporter, total, lambda state: state["media"] == "Reported YouTube fixture 1"
and state["status"] == 3 and not state["busy"] and not state["seekPending"]
and abs(state["position"] - bookmark) < 1,
"native history resumes a previously acknowledged position",
action="youtube", url=reported_urls[0])
resumed = wait_report(first_path, allowed_urls[0], lambda records: any(
record["event"] == "progress" and record["media_url"] == reported_urls[0]
for record in records[before_resume:]),
"first ready report follows the backend's initial resume acknowledgement")
initial_resume = next(record for record in resumed[before_resume:]
if record["event"] == "progress" and record["media_url"] == reported_urls[0])
if abs(initial_resume["position_seconds"] - bookmark) >= 1:
raise RuntimeError(f"initial resumed report exposed an unacknowledged zero timeline: {initial_resume}")
input_request(first_reporter, total, lambda state: state["status"] == 2,
"resumed reporting stream can pause", action="pause")
crossing = paused[first_reporter]["duration"] * 0.96
input_request(first_reporter, total, lambda state: at_position(state, crossing),
"seek acknowledges a watch-fact crossing", action="seek", position=crossing)
wait_report(first_path, allowed_urls[0], lambda records: any(
record["media_url"] == reported_urls[0] and watch_fact(record) for record in records),
"watch fact requires eligible actual decoded ratio, not launcher success")
input_request(first_reporter, total, lambda state: state["status"] == 3,
"native play continues from the acknowledged crossing", action="play")
ended = wait_states(total, lambda snapshots: snapshots[first_reporter]["status"] == 4,
"actual playback reaches its decoded end")
if ended[first_reporter]["position"] / ended[first_reporter]["duration"] < 0.95:
raise RuntimeError(f"native ended timeline unexpectedly reset before reporting: {ended[first_reporter]}")
wait_report(first_path, allowed_urls[0], lambda records: any(
record["event"] == "ended" and record["media_url"] == reported_urls[0] and watch_fact(record)
for record in records), "ended report retains actual final position rather than bookmark-reset zero")
input_request(first_reporter, total, lambda state: state["media"] == audio.name
and state["status"] == 3 and state["hasAudio"] and not state["hasVideo"],
"local audio replacement remains playable without reporting local media", media=audio)
local_boundary = wait_report(first_path, allowed_urls[0], lambda records: (
records[-1]["event"] == "closed" and records[-1]["media_url"] == reported_urls[0]
and watch_fact(records[-1])) if records else False,
"local replacement closes the final YouTube snapshot")
input_request(first_reporter, total, lambda state: state["status"] == 2,
"unreported local pause uses the same real model", action="pause")
input_request(first_reporter, total, lambda state: bool(state["error"]) and not state["busy"],
"fixture extraction failure reaches the actual GUI error path",
action="youtube", url=reported_urls[3], allow_error=True)
failed_boundary = read_reports(first_path, allowed_urls[0])
if any(record["event"] != "closed" or record["media_url"] != reported_urls[0]
for record in failed_boundary[len(local_boundary):]):
raise RuntimeError(f"local media or failed resolution emitted a progress observation: {failed_boundary}")
input_request(first_reporter, total, lambda state: state["media"] == "Reported YouTube fixture 3"
and state["status"] == 3 and not state["busy"] and not state["seekPending"]
and not state["error"],
"successful resolution recovers after an unreported failed open",
action="youtube", url=reported_urls[2], allow_error=True)
wait_report(first_path, allowed_urls[0], lambda records: any(
record["event"] == "progress" and record["media_url"] == reported_urls[2]
for record in records[len(failed_boundary):]), "recovery reports only successfully opened identity")
second_position = states[second_reporter]["duration"] * 0.2
input_request(second_reporter, total, lambda state: at_position(state, second_position),
"second file receives only its own acknowledged seek", action="seek", position=second_position)
wait_report(second_path, allowed_urls[1], lambda records: any(
progress_at(record, reported_urls[1], second_position) for record in records),
"second progress stream never receives first player's media identity")
quit_instance(second_reporter, total)
final_second = wait_report(second_path, allowed_urls[1], lambda records: (
records[-1]["event"] == "closed"
and abs(records[-1]["position_seconds"] - second_position) < 0.5) if records else False,
"normal GUI quit drains the second player's final acknowledged snapshot")
quit_instance(first_reporter, total - 1)
final_first = wait_report(first_path, allowed_urls[0], lambda records: (
records[-1]["event"] == "closed" and records[-1]["media_url"] == reported_urls[2])
if records else False, "normal GUI quit drains the first player's current identity")
if not final_first or not final_second or any(not path.read_bytes().endswith(b"\n") for path in report_paths):
raise RuntimeError("normal quit left an incomplete JSONL record")
remaining = wait_states(count, lambda _: True, "original nonreporting GUIs survive both reporting quits")
for pid, original in originals.items():
if remaining[pid]["media"] != original["media"] or settings(remaining[pid]) != settings(original):
raise RuntimeError(f"reporting actions changed an unrelated session: {original}; {remaining[pid]}")
print("PASS real progress launch: absolute spaced paths after --url, two isolated GUI streams, decoded initial/pause/seek/resume/quality/replacement/end/quit, eligible watch fact, local and failed-open exclusions")
else:
print("SKIP reporting GUI playback: yt-dlp and a supported JS runtime are required")
before = wait_states(count, lambda _: True, "all independent processes remain registered")
invoke(["--new-instance", "--help"])
wait_states(count, lambda _: True, "help never creates an extra application")
replacement = root / "replacement.mp4"
shutil.copy2(fixtures / "av.mp4", replacement)
invoke([str(replacement)])
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
for state in states.values()) == 1,
"default media delivery still reuses exactly one existing process")
changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]]
if len(changed) != 1:
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
if config.read_text() != config_text:
raise RuntimeError("native app startup modified config.toml")
print("PASS actual CLI instances: native startup config/defaults/invalid-file error/bypass/read-only settings, cold/default reuse, independent empty/local/YouTube, one window per PID, targeted media, original playback retained, help, warm replacement")
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
@@ -39,6 +486,7 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_cli_instances(root, app, fixtures)
finally:
os.close(descriptor)
subprocess.run(
+178
View File
@@ -1,9 +1,14 @@
import AppKit
import Darwin
import MediaPlayer
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") {
CLIInstanceCheck.run(configuration)
return
}
AppStartupCheck.run()
}
@@ -57,3 +62,176 @@ private enum AppStartupCheck {
NSApp.terminate(nil)
}
}
@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]
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
let stderrURL = directory.appendingPathComponent("stderr-\(ProcessInfo.processInfo.processIdentifier).log")
precondition(FileManager.default.createFile(atPath: stderrURL.path, contents: nil))
let errorLog = try! FileHandle(forWritingTo: stderrURL)
precondition(dup2(errorLog.fileDescriptor, STDERR_FILENO) >= 0)
try! errorLog.close()
setenv("PATH", settings["path"]!, 1)
_ = NSApplication.shared
_ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated { PlayerModel.shared.setVolume(0) }
}
// 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
window.ignoresMouseEvents = true
}
}
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated {
let model = PlayerModel.shared
let pid = ProcessInfo.processInfo.processIdentifier
// 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")
var pendingRequestID = ""
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 {
pendingRequestID = id
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
// Synthetic native key requests need foreground focus; cooperative activation
// can be refused by the unrelated app currently active on the workstation.
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.makeMain()
} else {
requestID = id
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
if let action = command["action"] as? String {
switch action {
case "youtube":
model.openYouTube(command["url"] as! String)
case "seek":
let position = command["position"] as! Double
precondition(model.seekRelative(position - model.state.position), "Native seek must be accepted")
case "pause":
precondition(model.perform(.pause), "Native pause must be accepted")
case "play":
precondition(model.perform(.play), "Native play must be accepted")
case "quality":
precondition(model.canChangeYouTubeQuality, "Native quality change must be available")
model.setYouTubeQuality((command["height"] as! Int))
case "close":
model.closeFile()
case "quit":
timer?.invalidate()
precondition(model.perform(.quit), "Native quit must be accepted")
return
default:
fatalError("Unknown native check action \(action)")
}
}
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
}
let snapshot: [String: Any] = [
"pid": pid,
"pids": applications.map(\.processIdentifier).sorted(),
"media": model.fileName ?? "",
"status": model.state.status.rawValue,
"position": model.state.position,
"duration": model.state.duration,
"seekPending": model.state.seek_pending,
"canSeek": model.canSeek,
"busy": model.isBusy,
"youtubeHeight": model.youtubeHeight ?? 0,
"canChangeQuality": model.canChangeYouTubeQuality,
"qualityHeights": model.youtubeHeights,
"windows": windows.count,
"hasAudio": model.state.has_audio,
"hasVideo": model.state.has_video,
"error": model.errorText ?? "",
"showBufferInfo": model.showsBufferInfo,
"automaticQuality": model.automaticYouTubeQuality,
"snapToEdges": model.compactWindow.snappingEnabled,
"requestID": requestID,
"pendingRequestID": pendingRequestID,
"active": NSApp.isActive,
"keyWindow": windows.first?.isKeyWindow ?? false,
"mainWindow": windows.first?.isMainWindow ?? false,
"editorText": editorText,
"compact": model.isCompact,
"onTop": model.isOnTop,
]
do {
let data = try JSONSerialization.data(withJSONObject: snapshot)
try data.write(to: directory.appendingPathComponent("state-\(pid).json"), options: .atomic)
} catch { fatalError("Could not publish CLI instance check: \(error)") }
}
}
terebiAppMain()
}
}
+154
View File
@@ -0,0 +1,154 @@
import AppKit
import TerebiConfiguration
@MainActor
private final class FixedQualityClock { var now = 1000.0 }
@main
@MainActor
struct AutomaticQualityPlaybackChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() {
FileHandle.standardError.write(Data(("FAIL: " + message + "\n").utf8))
exit(1)
}
}
// Only the infrastructure watchdog uses elapsed time. Adaptation uses an injected clock;
// playback readiness and network stalls are observable media events, not sleeps.
static func until(_ model: PlayerModel, _ message: String, _ condition: () -> Bool) {
let deadline = ContinuousClock.now.advanced(by: .seconds(20))
while !condition(), ContinuousClock.now < deadline {
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
}
require(condition(), message + " (height=\(model.youtubeHeight ?? 0), status=\(model.state.status), error=\(model.errorText ?? "none"))")
}
static func polled(_ model: PlayerModel) {
let revision = model.state.revision
until(model, "native model processes a fresh playback snapshot") { model.state.revision != revision }
}
static func main() {
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
let suite = "terebi.automatic-quality-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let clock = FixedQualityClock()
let model = PlayerModel(defaults: defaults, qualityClock: { clock.now })
defer { model.shutdown() }
model.setVolume(0)
require(!model.automaticYouTubeQuality, "automatic quality is opt-in")
model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
until(model, "default selects real 1080p and runs out of the gated network prefix") {
model.youtubeHeight == 1080 && model.canTogglePlayback &&
model.state.status == PLAYER_PLAYING && model.state.position > 0.5 && model.state.buffering
}
clock.now = 2000
polled(model)
require(model.youtubeHeight == 1080 && !model.resolvingYouTube,
"disabled automatic quality leaves a stalled manual/default stream alone")
model.setAutomaticYouTubeQuality(true)
polled(model)
let position = model.state.position
clock.now = 2003
until(model, "persistent stall automatically reopens actual 720p media") {
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
}
require(model.state.position >= position && model.state.position < position + 3,
"automatic quality reopening preserves current playback progress")
polled(model)
clock.now = 2063
until(model, "sustained health automatically restores actual 1080p media") {
model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && !model.state.seek_pending
}
model.setYouTubeQuality(model.youtubeHeight)
require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING,
"reselecting the current manual resolution disables auto without disrupting playback")
model.setAutomaticYouTubeQuality(true)
let reopened = PlayerModel(defaults: defaults, qualityClock: { clock.now })
require(!reopened.automaticYouTubeQuality, "session opt-in does not survive player restart")
reopened.shutdown()
model.setYouTubeQuality(nil)
require(!model.automaticYouTubeQuality && model.youtubeQuality == nil,
"default quality is also a manual override")
let manual = PlayerModel(defaults: defaults, configuration: PlayerConfiguration(automaticQuality: true),
qualityClock: { clock.now })
require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration")
manual.shutdown()
// Control only the external extractor; media decoding and all model transitions stay real.
guard let controlPath = ProcessInfo.processInfo.environment["TEREBI_QUALITY_CONTROL"] else {
require(false, "isolated extraction controls are available")
return
}
let control = URL(fileURLWithPath: controlPath, isDirectory: true)
func mark(_ name: String) {
require(FileManager.default.createFile(atPath: control.appendingPathComponent(name).path,
contents: Data()), "publish extraction control " + name)
}
func marked(_ name: String) -> Bool {
FileManager.default.fileExists(atPath: control.appendingPathComponent(name).path)
}
model.setYouTubeQuality(720)
until(model, "manual selection opens real healthy 720p media") {
model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
}
let selectedTitle = model.fileName
let selectedQuality = model.youtubeQuality
mark("gate-1080")
clock.now = 3000
model.setAutomaticYouTubeQuality(true)
polled(model)
let healthyPosition = model.state.position
until(model, "healthy native playback continues before the controlled upshift") {
model.state.position > healthyPosition + 0.02
}
clock.now = 3060
until(model, "automatic upshift reaches the selected extraction gate") {
marked("entered-1080") && model.resolvingYouTube
}
require(model.setPlayback(playing: false), "pause is accepted during automatic extraction")
mark("release-1080")
until(model, "cancelled extraction finishes and native pause is acknowledged") {
marked("finished-1080") && !model.resolvingYouTube && model.canTogglePlayback &&
model.state.status == PLAYER_PAUSED && !model.state.seek_pending
}
let cancelledPosition = model.state.position
for _ in 0..<3 {
polled(model)
require(model.state.status == PLAYER_PAUSED && abs(model.state.position - cancelledPosition) < 0.05 &&
model.state.has_video && model.state.has_audio &&
model.youtubeHeight == 720 && model.youtubeQuality == selectedQuality &&
model.fileName == selectedTitle && model.errorText == nil,
"late extraction cannot reopen different quality or resume deliberately paused media")
}
try! FileManager.default.removeItem(at: control.appendingPathComponent("gate-1080"))
require(model.seekRelative(37 - model.state.position), "original paused media accepts a nonzero seek")
until(model, "native original media acknowledges the seek") {
model.canChangeYouTubeQuality && model.state.status == PLAYER_PAUSED &&
!model.state.seek_pending && abs(model.state.position - 37) < 0.05
}
mark("fail-1080")
for playing in [false, true] {
require(model.setPlayback(playing: playing), "original playback state is selectable before quality failure")
until(model, "original native playback reaches the requested state before quality failure") {
model.canChangeYouTubeQuality && model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) &&
(!playing || model.state.position > 37.1)
}
let originalPosition = model.state.position
model.setYouTubeQuality(1080)
until(model, "failed manual quality switch restores the original native playback state") {
model.errorText != nil && !model.resolvingYouTube && model.canTogglePlayback &&
model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) &&
(!playing || model.state.position > originalPosition + 0.1)
}
require(model.state.has_video && model.state.has_audio && model.youtubeHeight == 720 &&
model.youtubeQuality == selectedQuality && model.fileName == selectedTitle &&
model.state.position >= originalPosition - 0.05 &&
model.state.position < originalPosition + (playing ? 2 : 0.05),
"failed manual quality switch retains original selected media and playback progress")
}
model.closeFile()
print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings/cancelled extraction pause/failed manual quality playing+paused preservation")
}
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Run adaptive PlayerModel playback against real loopback media and offline yt-dlp metadata."""
import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import tempfile
from streaming import StreamingServer
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary", type=Path)
parser.add_argument("fixtures", type=Path)
args = parser.parse_args()
extractor = shutil.which("yt-dlp")
if extractor is None:
extractor = next((str(path) for path in (Path("/opt/homebrew/bin/yt-dlp"), Path("/usr/local/bin/yt-dlp"))
if os.access(path, os.X_OK)), None)
if extractor is None:
print("SKIP native adaptive playback: yt-dlp is not installed; controlled-clock policy checks still run")
return 0
required = ("quality-1080.mp4", "quality-720.mp4")
if any(not (args.fixtures / name).is_file() for name in required):
parser.error("Missing real quality playback fixtures")
with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
root = Path(directory)
formats = []
for height, width in ((720, 1280), (1080, 1920)):
path = "/media/quality-720.mp4" if height == 720 else "/stall/quality-low/quality-1080.mp4?after_seconds=2"
formats.append({"format_id": str(height), "height": height, "width": width,
"url": server.base_url + path, "protocol": "http", "ext": "mp4",
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
"http_headers": {"X-Terebi-Fixture": "combined"}})
info = {"id": "aqz-KE-bpKQ", "title": "Automatic quality playback fixture", "duration": 120,
"is_live": False, "webpage_url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
"extractor": "youtube", "extractor_key": "Youtube", "formats": formats}
metadata = root / "info.json"
metadata.write_text(json.dumps(info))
wrapper = root / "yt-dlp"
control = root / "control"
control.mkdir()
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
+ "control=" + shlex.quote(str(control)) + "\n"
+ 'if [[ "$*" == *"[height=1080]"* ]]; then\n'
+ ' if [[ -f "$control/fail-1080" ]]; then\n'
+ ' echo "Fixture extraction deliberately failed" >&2; exit 1\n'
+ ' fi\n'
+ ' if [[ -f "$control/gate-1080" ]]; then\n'
+ " " + shlex.quote(extractor) + " --load-info-json " + shlex.quote(str(metadata))
+ ' "${args[@]:0:${#args[@]}-2}" > "$control/output-1080.json"\n'
+ ' trap \': > "$control/finished-1080"; exit 0\' TERM\n'
+ ' : > "$control/entered-1080"\n'
+ ' while [[ ! -f "$control/release-1080" ]]; do sleep 0.01; done\n'
+ ' cat "$control/output-1080.json" || true\n'
+ ' : > "$control/finished-1080"\n'
+ ' exit 0\n'
+ ' fi\n'
+ 'fi\nexec ' + shlex.quote(extractor)
+ " --load-info-json " + shlex.quote(str(metadata))
+ ' "${args[@]:0:${#args[@]}-2}"\n')
wrapper.chmod(0o755)
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""),
TEREBI_QUALITY_CONTROL=str(control))
try:
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
except subprocess.TimeoutExpired:
raise SystemExit("FAIL: adaptive playback exceeded its infrastructure deadline")
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())
+145
View File
@@ -0,0 +1,145 @@
import Foundation
@main
struct AutomaticQualityChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
static func sample(at now: Double, ahead: Double? = 30, height: Int? = 720) -> AutomaticQuality.Sample {
.init(position: 100 + now, duration: 1000, ahead: ahead, height: height,
heights: [240, 480, 720, 1080, 2160])
}
static func main() {
var policy = AutomaticQuality()
for second in 0..<6 {
require(policy.choose(sample(at: Double(second), ahead: 3), at: Double(second)) == nil,
"brief low reserve does not change quality")
}
require(policy.choose(sample(at: 6, ahead: 3), at: 6) == 480, "persistent low reserve steps down one rung")
policy.resetHealth() // Automatic reopen resets evidence without losing its cooldown.
for second in 7..<36 {
require(policy.choose(sample(at: Double(second), ahead: 3, height: 480), at: Double(second)) == nil,
"automatic reopen preserves the 30-second switch cooldown")
}
require(policy.choose(sample(at: 36, ahead: 3, height: 480), at: 36) == 240,
"continued shortage may downshift after cooldown")
policy = AutomaticQuality()
for second in 0..<60 {
require(policy.choose(sample(at: Double(second), height: 240), at: Double(second)) == nil,
"upshift waits for sustained healthy recorded playback")
}
require(policy.choose(sample(at: 60, height: 240), at: 60) == 480,
"healthy playback increases exactly one rung")
for second in 61..<90 {
require(policy.choose(sample(at: Double(second), ahead: 1, height: 480), at: Double(second)) == nil,
"a sudden shortage cannot immediately reverse an upshift")
}
require(policy.choose(sample(at: 90, ahead: 1, height: 480), at: 90) == 240,
"downshift becomes possible after upshift cooldown")
policy = AutomaticQuality()
for second in 0...5 {
require(policy.choose(sample(at: Double(second), ahead: 3), at: Double(second)) == nil, "low reserve warmup")
}
require(policy.choose(sample(at: 6, ahead: 8), at: 6) == nil, "middle reserve clears low-water evidence")
for second in 7..<13 {
require(policy.choose(sample(at: Double(second), ahead: 3), at: Double(second)) == nil,
"hysteresis requires a fresh continuous shortage")
}
require(policy.choose(sample(at: 13, ahead: 3), at: 13) == 480, "new shortage has its own threshold")
policy = AutomaticQuality()
for second in 0...90 {
require(policy.choose(sample(at: Double(second), ahead: 19, height: 480), at: Double(second)) == nil,
"middle reserve is not healthy enough for an upshift")
}
for suppression in ["disabled", "paused", "seeking", "opening", "local", "missing height", "empty heights", "EOF"] {
policy = AutomaticQuality()
for second in 0...150 {
let now = Double(second)
var input = sample(at: now, ahead: 1)
switch suppression {
case "disabled": input.enabled = false
case "paused": input.playing = false
case "seeking": input.seeking = true
case "opening": input.opening = true
case "local": input.network = false
case "missing height": input.height = nil
case "empty heights": input.heights = []
default: input.duration = input.position + 14
}
require(policy.choose(input, at: now) == nil, suppression + " suppresses quality switching")
}
}
for interruption in ["pause", "seek", "open"] {
policy = AutomaticQuality()
require(policy.choose(sample(at: 0), at: 0) == nil, "start healthy evidence")
var input = sample(at: 59)
input.playing = interruption != "pause"
input.seeking = interruption == "seek"
input.opening = interruption == "open"
require(policy.choose(input, at: 59) == nil, "interrupt healthy evidence")
require(policy.choose(sample(at: 60), at: 60) == nil, "resume starts fresh health")
require(policy.choose(sample(at: 119), at: 119) == nil, "no pre-interruption health is reused")
require(policy.choose(sample(at: 120), at: 120) == 1080, "fresh uninterrupted health can upshift")
}
policy = AutomaticQuality()
var sparse = sample(at: 0, ahead: 1)
sparse.heights = [2160, 1080, 480, 144, 480, 0, -1]
require(policy.choose(sparse, at: 0) == nil, "sparse heights warmup")
sparse.position += 6
require(policy.choose(sparse, at: 6) == 480, "nearest lower available rung handles sparse heights")
policy = AutomaticQuality()
sparse.height = 2160
require(policy.choose(sparse, at: 0) == 1080, "enabling auto caps an existing high-resolution selection")
policy = AutomaticQuality()
for second in 0...150 {
require(policy.choose(sample(at: Double(second), height: 1080), at: Double(second)) == nil,
"automatic upshift never exceeds 1080p")
}
for live in [false, true] {
policy = AutomaticQuality()
for second in 0..<120 {
let now = Double(second)
var input = sample(at: now, ahead: live ? 0 : nil, height: 480)
input.live = live
input.duration = 0
require(policy.choose(input, at: now) == nil, "live/unknown reserve needs 120 seconds of stability")
}
var input = sample(at: 120, ahead: live ? 0 : nil, height: 480)
input.live = live
input.duration = 0
require(policy.choose(input, at: 120) == 720, "live/unknown reserve can cautiously upshift")
policy = AutomaticQuality()
input.buffering = true
for second in 0..<3 {
require(policy.choose(input, at: Double(second)) == nil, "brief live/unknown stall is tolerated")
}
require(policy.choose(input, at: 3) == 240, "persistent live/unknown stall downshifts")
}
policy = AutomaticQuality()
let frozen = sample(at: 0, ahead: nil)
for second in 0..<6 {
require(policy.choose(frozen, at: Double(second)) == nil, "position stall first waits for progress then hysteresis")
}
require(policy.choose(frozen, at: 6) == 480, "lack of playback progress also detects a stall")
policy = AutomaticQuality()
require(policy.choose(sample(at: 0, ahead: 1), at: 0) == nil, "failure scenario warmup")
require(policy.choose(sample(at: 6, ahead: 1), at: 6) == 480, "failure scenario requests a switch")
policy.extractionFailed(at: 7)
policy.resetHealth()
for second in 8..<127 {
require(policy.choose(sample(at: Double(second), ahead: 1), at: Double(second)) == nil,
"extraction failure preserves old height and backs off for 120 seconds")
}
require(policy.choose(sample(at: 127, ahead: 1), at: 127) == 480,
"failure backoff permits a later attempt, not a retry every poll")
print("Automatic quality checks passed")
}
}
+7 -12
View File
@@ -93,7 +93,7 @@ struct CompactWindowChecks {
"target width shrinks to usable bounds without distorting ordinary aspect")
}
static func windowBehavior(defaults: UserDefaults) throws {
static func windowBehavior() throws {
_ = NSApplication.shared
guard let screen = NSScreen.main else {
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
@@ -127,7 +127,7 @@ struct CompactWindowChecks {
let originalHides = window.hidesOnDeactivate
let originalDrag = window.isMovableByWindowBackground
let identity = ObjectIdentifier(window)
let controller = CompactWindowController(defaults: defaults)
let controller = CompactWindowController()
defer { controller.restore(resumeFullScreen: false) }
controller.attach(window)
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
@@ -182,14 +182,14 @@ struct CompactWindowChecks {
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16)
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
controller.snappingEnabled = false
let second = CompactWindowController(defaults: defaults)
try require(!second.snappingEnabled, "disabled preference persists across controller instances")
let second = CompactWindowController(snappingEnabled: false)
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
window.setFrame(nearEdge, display: false)
controller.snapAfterDrag()
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
second.snappingEnabled = true
let third = CompactWindowController(defaults: defaults)
try require(third.snappingEnabled, "re-enabled preference persists across controller instances")
let third = CompactWindowController(snappingEnabled: false)
try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
controller.snappingEnabled = true
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
window.setFrame(resized, display: false)
@@ -218,13 +218,8 @@ struct CompactWindowChecks {
}
static func main() throws {
let suite = "terebi.compact-window-checks.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suite) else {
throw Failure(description: "FAIL: cannot create isolated preference suite")
}
defer { defaults.removePersistentDomain(forName: suite) }
try geometry()
try windowBehavior(defaults: defaults)
try windowBehavior()
print("Compact window checks passed")
}
}
+274
View File
@@ -0,0 +1,274 @@
import Foundation
import TerebiConfiguration
@main
struct ConfigurationChecks {
struct Failure: Error, CustomStringConvertible {
let description: String
}
static func require(_ condition: Bool, _ message: String) throws {
if !condition { throw Failure(description: "FAIL: " + message) }
}
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)
defer { try? files.removeItem(at: root) }
let home = root.appendingPathComponent("home")
let xdg = root.appendingPathComponent("xdg")
let fallback = home.appendingPathComponent(".config/terebi/config.toml")
let config = xdg.appendingPathComponent("terebi/config.toml")
let defaults = PlayerConfiguration()
let printed = root.appendingPathComponent("printed-defaults.toml")
try Data(PlayerConfiguration.defaultTOML.utf8).write(to: printed)
try require(try PlayerConfiguration.load(from: printed) == defaults,
"printed TOML is directly usable and preserves all default behavior and bindings")
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
try require(PlayerConfiguration.fileURL(environment: ["XDG_CONFIG_HOME": xdg.path], homeDirectory: home) == config,
"absolute XDG_CONFIG_HOME selects terebi/config.toml")
for environment in [[:], ["XDG_CONFIG_HOME": ""], ["XDG_CONFIG_HOME": "relative/config"]] {
try require(PlayerConfiguration.fileURL(environment: environment, homeDirectory: home) == fallback,
"unset, empty, and relative XDG_CONFIG_HOME fall back to ~/.config")
}
try require(try PlayerConfiguration.load(from: fallback) == defaults, "missing files use built-in defaults")
try require(try PlayerConfiguration.loadForApplication(arguments: [], environment: [:], homeDirectory: home) == defaults,
"application startup uses defaults when its resolved config is missing")
try require(!files.fileExists(atPath: home.path), "missing-file loading creates neither home, config directory, nor file")
try files.createDirectory(at: config.deletingLastPathComponent(), withIntermediateDirectories: true)
let original = Data("""
# Sections and nested keys can be quoted, with comments and whitespace.
['playback']
'automatic_quality' = true
[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 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
session.automaticQuality = false
session.snapToEdges = true
try require(session == defaults, "controls can override all settings within the session")
try require(try Data(contentsOf: config) == original, "loading and session overrides do not change config bytes")
try require(try PlayerConfiguration.load(from: config) == configured, "a fresh load resets session overrides to file values")
let partial = root.appendingPathComponent("partial.toml")
try Data("[playback]\nautomatic_quality = true\n".utf8).write(to: partial)
try require(try PlayerConfiguration.load(from: partial) == PlayerConfiguration(automaticQuality: true),
"partial configuration retains defaults for omitted settings")
let empty = root.appendingPathComponent("empty.toml")
try Data("# Defaults only\n\n".utf8).write(to: empty)
try require(try PlayerConfiguration.load(from: empty) == defaults, "empty/comment-only TOML uses defaults")
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 playback and UI defaults")
let expectedKeys = try KeyBindings(overrides: ["play": ["x"], "pause": ["x", "ctrl+x"], "mute": [],
"quality_1080p": ["alt+1"], "show_buffer_info": ["alt+b"],
"hide_buffer_info": ["alt+b"]])
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[playback]\n".utf8).write(to: emptySections)
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
let reordered = root.appendingPathComponent("reordered.toml")
try Data("""
[keys]
play = "x"
pause = "x"
[ui]
buffer_info = true
compact_snap_to_edges = false
[playback]
automatic_quality = true
""".utf8).write(to: reordered)
try require(try PlayerConfiguration.load(from: reordered) ==
PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false,
keys: KeyBindings(overrides: ["play": ["x"], "pause": ["x"]])),
"playback after keys and UI retains every section's settings and bindings")
let invalid: [(String, String, [String])] = [
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
("unknown", "unknown_setting = true\n", ["unknown_setting"]),
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
("playback-scalar", "playback = true\n", ["playback"]),
("playback-unknown", "[playback]\nunknown_setting = true\n", ["playback.unknown_setting"]),
("playback-misplaced", "[ui]\nautomatic_quality = true\n", ["ui.automatic_quality"]),
("ui-unknown", "[ui]\nunknown_setting = true\n", ["ui.unknown_setting"]),
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
("string", "[ui]\nbuffer_info = \"true\"\n", []),
("integer", "[playback]\nautomatic_quality = 1\n", []),
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
("table", "[playback.automatic_quality]\nenabled = true\n", ["automatic_quality"]),
("ui-scalar", "ui = true\n", ["ui"]),
("keys-scalar", "keys = true\n", ["keys"]),
("key-bool", "[keys]\nplay = true\n", ["keys.play"]),
("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, 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", containing: fragments)
try require(try Data(contentsOf: url) == bytes, "rejected \(name) config remains unchanged")
}
let invalidUTF8 = root.appendingPathComponent("invalid-utf8.toml")
let invalidBytes = Data([0xff, 0xfe, 0x80])
try invalidBytes.write(to: invalidUTF8)
try requireLoadError(at: invalidUTF8, "invalid UTF-8 configuration")
try require(try Data(contentsOf: invalidUTF8) == invalidBytes, "invalid UTF-8 config remains unchanged")
let directory = root.appendingPathComponent("directory.toml")
try files.createDirectory(at: directory, withIntermediateDirectories: false)
try requireLoadError(at: directory, "directory instead of config file")
let malformed = root.appendingPathComponent("syntax.toml")
let inaccessible = partial.appendingPathComponent("config.toml") // A regular file cannot be traversed as a directory.
for url in [malformed, inaccessible, directory] {
try require(try PlayerConfiguration.loadForApplication(arguments: ["--config-file", url.absoluteString, "--no-config"],
environment: environment, homeDirectory: home) == defaults,
"--no-config bypasses malformed, inaccessible, and directory config paths")
}
let badXDG = root.appendingPathComponent("bad-xdg")
let badConfig = badXDG.appendingPathComponent("terebi/config.toml")
try files.createDirectory(at: badConfig.deletingLastPathComponent(), withIntermediateDirectories: true)
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,
"--no-config also bypasses automatic XDG loading")
try require(try Data(contentsOf: badConfig) == malformedBytes, "bypassing config never repairs or rewrites it")
try require(!files.fileExists(atPath: home.path), "all checks leave the isolated fallback home uncreated")
print("Configuration checks passed")
}
}
+219 -30
View File
@@ -9,6 +9,9 @@
#include <functional>
#include <memory>
#include <string>
#include <signal.h>
#include <sys/resource.h>
#include <unistd.h>
static void require(bool condition, const char *message) {
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
@@ -33,8 +36,8 @@ static void validBuffer(PlayerState state) {
require(std::isfinite(state.duration) && state.duration > 0 &&
state.buffered_start >= 0 && buffered(state) && state.buffered_end <= state.duration,
"retained range is nonempty and bounded by finite media duration");
require(std::abs(state.buffered_start - state.position) < .00001,
"retained range follows the authoritative playback position, not a loaded prefix");
require(state.buffered_start <= state.position && state.position <= state.buffered_end,
"the retained interval covers the current playhead");
}
// Only these deadlines use elapsed time: a stuck command/read must fail the check,
@@ -90,6 +93,7 @@ struct GateState {
bool entered, released;
uint64_t waiting, requests, ranges, bytes, total;
double audioEnd, videoEnd;
uint64_t connections;
};
static GateState gateState(const std::string &base, const std::string &token) {
@@ -98,7 +102,8 @@ static GateState gateState(const std::string &base, const std::string &token) {
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
[state[@"total"] unsignedLongLongValue],
[state[@"prefix_ends"][@"audio"] doubleValue], [state[@"prefix_ends"][@"video"] doubleValue]};
[state[@"prefix_ends"][@"audio"] doubleValue], [state[@"prefix_ends"][@"video"] doubleValue],
[state[@"connections"] unsignedLongLongValue]};
}
static void stillBlocked(const std::string &base, const std::string &token) {
@@ -116,7 +121,7 @@ static std::string headersFor(const std::string &path) {
static bool openNetwork(Player *player, const std::string &base, const std::string &videoPath,
const std::string &audioPath = {}, double duration = 4, bool live = false,
bool includeHeaders = true) {
bool includeHeaders = true, const std::string &mediaURL = {}, double initialPosition = 0) {
// Locals intentionally die immediately after the call: the asynchronous engine
// must own both selected URLs and both selected HTTP-header blocks.
auto video = base + videoPath;
@@ -124,7 +129,8 @@ static bool openNetwork(Player *player, const std::string &base, const std::stri
auto videoHeaders = includeHeaders ? headersFor(videoPath) : std::string{};
auto audioHeaders = includeHeaders ? headersFor(audioPath) : std::string{};
PlayerNetworkSource source{video.c_str(), audio.empty() ? nullptr : audio.c_str(),
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live};
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live,
mediaURL.empty() ? nullptr : mediaURL.c_str(), initialPosition};
return player_open_network(player, &source);
}
@@ -330,20 +336,83 @@ static void prefixBound(PlayerState state, GateState gate, bool splitAudio = fal
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
}
static void bufferCoverage(Player *player, const std::string &base) {
require(openNetwork(player, base, "/media/streaming.mp4", "", 30),
"open long network fixture for bounded retained coverage");
auto initial = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"paused network source exposes actual retained media");
require(initial.buffered_end < initial.duration,
"bounded queues do not report an entire downloaded movie as retained");
require(player_play(player), "play to advance retained network coverage");
until(player, [=](auto s) {
return s.status == PLAYER_PLAYING && s.position > initial.buffered_end && buffered(s) &&
s.buffered_end > initial.buffered_end;
}, "retained horizon advances with real playback and refills");
require(player_close(player), "close advancing buffered source");
static void cachedWindow(Player *player, const std::string &base) {
const std::string token = "cached-window";
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=100", "", 120),
"open a two-minute source with a controlled future network stall");
auto initial = until(player, [](auto s) {
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end >= 59;
}, "paused playback prefetches a minute without waiting to start");
require(initial.buffered_end < 65, "read-ahead is time-bounded rather than a full download");
seekCompleted(player, 50, PLAYER_PAUSED);
auto retained = until(player, [&](auto s) {
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end > 95 &&
gateState(base, token).entered;
}, "cache follows a new playhead until the future network gate");
require(retained.position - retained.buffered_start >= 29,
"a reusable thirty-second rewind window survives forward playback");
require(retained.buffered_start > 15, "old media is evicted instead of caching the whole movie");
const auto before = gateState(base, token);
auto rewind = seekCompleted(player, 20.5, PLAYER_PAUSED);
require(buffered(rewind), "cached rewind preserves the retained interval");
auto after = gateState(base, token);
require(after.requests == before.requests && after.ranges == before.ranges,
"rewinding within history makes no new HTTP request");
stillBlocked(base, token);
seekCompleted(player, 45, PLAYER_PAUSED);
after = gateState(base, token);
require(after.requests == before.requests && after.ranges == before.ranges,
"forward seeking inside retained media makes no new HTTP request");
stillBlocked(base, token);
seekCompleted(player, 5, PLAYER_PAUSED);
until(player, [&](auto s) {
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end > 6 &&
gateState(base, token).requests > before.requests;
}, "seeking before retained history refills missing media over HTTP");
require(player_close(player), "close cache while the original future response remains blocked");
closed(player);
stillBlocked(base, token);
std::puts("PASS sliding cache: minute read-ahead/thirty-second history/keyframe replay/cached bidirectional seeks/miss refill/cancel");
}
static void chunkedFetch(Player *player, const std::string &base, bool split) {
const std::string videoToken = split ? "chunked-video" : "chunked-combined";
const std::string audioToken = "chunked-audio";
auto path = [](const std::string &token) {
return "/chunked-media/" + token + "/cache.mp4?after_seconds=2";
};
require(openNetwork(player, base, path(videoToken) + (split ? "" : "&keep_alive=1"),
split ? path(audioToken) : "", 120),
"open recorded media whose CDN throttles oversized requests");
require(player_play(player), "start playback while the minute reserve fills");
until(player, [&](auto s) {
const auto video = gateState(base, videoToken);
const bool throttled = video.entered || (split && gateState(base, audioToken).entered);
require(!(s.position > .5 && s.buffering && throttled),
"oversized HTTP request starves playback before a minute can buffer");
return s.status == PLAYER_PLAYING && buffered(s) && s.buffered_end - s.position >= 59;
}, "finite HTTP requests build a minute of playable reserve without CDN throttling");
require(gateState(base, videoToken).requests >= 2,
"minute read-ahead continues across the first bounded HTTP response");
if (split) require(gateState(base, audioToken).requests >= 2,
"secondary audio also continues bounded HTTP requests");
else require(gateState(base, videoToken).connections == 1,
"minute reserve reuses one HTTP connection across bounded responses");
require(player_pause(player), "pause before an out-of-cache seek");
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause chunked source");
seekCompleted(player, 119.5, PLAYER_PAUSED);
require(player_play(player), "resume the final partial HTTP range");
until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "bounded final response reaches true media EOF");
require(!gateState(base, videoToken).entered &&
(!split || !gateState(base, audioToken).entered),
"initial fetch, continuation and seek never use CDN-throttled ranges");
require(player_close(player), "close chunked source");
closed(player);
std::puts(split ? "PASS bounded split fetch: minute reserve/secondary audio/range continuation/seek/EOF"
: "PASS bounded combined fetch: minute reserve/range continuation/seek/EOF");
}
static void bufferCoverage(Player *player, const std::string &base) {
const std::string token = "buffer-release";
require(openNetwork(player, base, "/stall/" + token + "/streaming.mp4", "", 30),
@@ -373,7 +442,7 @@ static void bufferCoverage(Player *player, const std::string &base) {
}, "released server gate advances retained coverage beyond withheld timestamps");
require(player_close(player), "close released buffered source");
closed(player);
std::puts("PASS retained coverage: bounded queues/advancing clock/stalled timestamp bounds/B-frame dependencies/gate-release refill");
std::puts("PASS retained coverage: supplied timestamp bounds/B-frame dependencies/gate-release refill");
}
static void blockedPlayback(Player *player, const std::string &base, const std::string &token,
@@ -410,30 +479,28 @@ static void cancellation(Player *player, const std::string &base, const std::str
}
seekCompleted(player, 28, (splitAudio || splitVideo) ? PLAYER_PLAYING : PLAYER_PAUSED);
stillBlocked(base, token);
auto afterSeek = gateState(base, token);
require(afterSeek.requests > 1 && afterSeek.ranges > 1, "seek replaces stalled read with an actual new byte-range request");
require(player_play(player), "resume at seek target with stale read still blocked");
until(player, [](auto s) {
return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1 && buffered(s);
}, "retained coverage follows new stalled-read seek target");
until(player, [&](auto s) {
auto afterSeek = gateState(base, token);
return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1 && buffered(s) &&
afterSeek.requests > 1 && afterSeek.ranges > 1;
}, "new byte-range refill reaches stalled-read seek target");
stillBlocked(base, token);
require(player_pause(player), "pause high-timestamp coverage before backward seek");
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"retain high-timestamp media before invalidating it");
seekCompleted(player, .5, PLAYER_PAUSED);
auto rewound = until(player, [](auto s) {
until(player, [](auto s) {
return s.status == PLAYER_PAUSED && !s.seek_pending && buffered(s);
}, "backward seek rebuilds retained coverage at the new target");
require(rewound.buffered_end < 28,
"backward seek drops the old high-water horizon instead of retaining distant media");
stillBlocked(base, token);
require(player_close(player), "close resumed formerly stalled source");
closed(player);
std::puts(splitAudio
? "PASS stalled secondary audio: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
? "PASS stalled secondary audio: required-track bound/seek acknowledgement/new-target coverage/backward refill"
: splitVideo
? "PASS stalled primary video: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
: "PASS stalled combined input: bounded prefix/pause/seek invalidation/new-target coverage/backward high-water clearing");
? "PASS stalled primary video: required-track bound/seek acknowledgement/new-target coverage/backward refill"
: "PASS stalled combined input: bounded prefix/pause/seek acknowledgement/new-target coverage/backward refill");
}
blockedOpening(player, base, "opening-close");
@@ -494,12 +561,131 @@ static void failures(Player *player, const std::string &base) {
std::puts("PASS actual HTTP header denial/503 failure/useful errors/cleared state/split-source recovery");
}
static NSArray<NSDictionary *> *progressRecords(const std::string &path) {
NSString *text = [NSString stringWithContentsOfFile:[NSString stringWithUTF8String:path.c_str()]
encoding:NSUTF8StringEncoding error:nullptr];
if (!text) return @[];
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
NSMutableArray<NSDictionary *> *records = [NSMutableArray array];
for (NSUInteger index = 0; index + 1 < lines.count; ++index) {
id record = [NSJSONSerialization JSONObjectWithData:[lines[index] dataUsingEncoding:NSUTF8StringEncoding]
options:0 error:nullptr];
require([record isKindOfClass:[NSDictionary class]], "native reporting produces complete JSON records");
[records addObject:record];
}
return records;
}
static void reporting(const std::string &base, const std::string &fixtures) {
char temporary[] = "/tmp/terebi-native-progress-XXXXXX";
require(mkdtemp(temporary) != nullptr, "isolated native report directory");
const std::string path = std::string(temporary) + "/native.jsonl";
const std::string first = "https://www.youtube.com/watch?v=AAAAAAAAAAA";
const std::string second = "https://www.youtube.com/watch?v=BBBBBBBBBBB";
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
char error[512]{};
NSString *nativePath = [NSString stringWithUTF8String:path.c_str()];
require([@"original" writeToFile:nativePath atomically:NO encoding:NSUTF8StringEncoding error:nullptr],
"create existing destination sentinel");
require(!player_set_progress_file(player.get(), path.c_str(), error) && error[0] &&
player_get_state(player.get()).status == PLAYER_EMPTY,
"report initialization error leaves playback state usable");
require([[NSString stringWithContentsOfFile:nativePath encoding:NSUTF8StringEncoding error:nullptr]
isEqualToString:@"original"], "native initialization cannot overwrite an existing destination");
require(unlink(path.c_str()) == 0, "remove only the test-owned sentinel");
require(player_set_progress_file(player.get(), path.c_str(), error), "initialize native reporting before playback");
require(player_set_volume(player.get(), 0), "mute native reporting fixture");
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first, 50),
"open canonical source with initial resume intent");
until(player.get(), [&](auto state) {
auto rows = progressRecords(path);
return state.status == PLAYER_PAUSED && !state.seek_pending && rows.count > 0;
}, "backend acknowledges initial restoration before report");
auto rows = progressRecords(path);
require([rows[0][@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]] &&
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
[rows[0][@"completion_eligible"] boolValue],
"initial report is restored native position, not pre-resume zero");
require(openNetwork(player.get(), base, "/failure/av.mp4", "", 4, false, true, second),
"failed requested identity accepted asynchronously");
until(player.get(), [](auto state) { return state.status == PLAYER_ERROR; }, "failed reported open", true);
require(openNetwork(player.get(), base, "/media/audio.wav", "", 4, false, true, first),
"audio-only input with canonical identity opens");
until(player.get(), [&](auto state) {
auto current = progressRecords(path);
return state.status == PLAYER_PAUSED && state.has_audio && !state.has_video &&
[current.lastObject[@"duration_seconds"] doubleValue] == 4 &&
[current.lastObject[@"event"] isEqualToString:@"progress"];
}, "audio-only native report");
rows = progressRecords(path);
require(![rows.lastObject[@"completion_eligible"] boolValue], "actual audio-only input cannot mark watched");
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, true, true, first),
"live source with known duration opens");
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_video; },
"reported live input ready");
require(player_close(player.get()), "close actual reported live input");
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "reported live close");
require(openNetwork(player.get(), base, "/stall-open/report-cancelled/streaming.mp4", "", 30,
false, true, second, 29),
"gated initial resume opens");
until(player.get(), [&](auto state) {
return state.status == PLAYER_OPENING && gateState(base, "report-cancelled").entered;
}, "reported initial open remains unacknowledged");
require(player_close(player.get()), "cancel reported open before ready");
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "cancelled report open close");
require(player_shutdown(player.get(), error), "normal shutdown drains native reporter");
rows = progressRecords(path);
bool sawLive = false;
for (NSDictionary *record in rows) {
require([record[@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]],
"failed/cancelled opens never report their requested identity");
if ([record[@"duration_seconds"] doubleValue] == 120 &&
[record[@"position_seconds"] doubleValue] == 0) {
sawLive = true;
require(![record[@"completion_eligible"] boolValue], "live known-duration records stay ineligible");
}
}
require(sawLive && [rows.lastObject[@"event"] isEqualToString:@"closed"],
"native close/quit retain terminal facts for acknowledged media");
player.reset();
const auto failedPath = std::string(temporary) + "/write-failure.jsonl";
player.reset(player_create());
require(player_set_progress_file(player.get(), failedPath.c_str(), error), "initialize failure-test reporter");
require(player_set_volume(player.get(), 0), "mute failure-test playback");
struct rlimit previous{}, limited{};
require(getrlimit(RLIMIT_FSIZE, &previous) == 0, "read original file-size limit");
limited = previous;
limited.rlim_cur = 1;
auto previousSignal = signal(SIGXFSZ, SIG_IGN);
require(previousSignal != SIG_ERR && setrlimit(RLIMIT_FSIZE, &limited) == 0, "force actual reporting write failure");
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first),
"playback opens with failing output");
until(player.get(), [&](auto state) {
return state.status == PLAYER_PAUSED && player_get_progress_error(player.get(), error);
}, "write error surfaces independently from decoder status");
require(setrlimit(RLIMIT_FSIZE, &previous) == 0 && signal(SIGXFSZ, previousSignal) != SIG_ERR,
"restore resource limit and signal handler");
require(error[0] && player_play(player.get()), "reporting failure does not prevent playback");
until(player.get(), [](auto state) { return state.status == PLAYER_PLAYING && state.position > .1; },
"actual playback continues after reporting failure");
seekCompleted(player.get(), 5, PLAYER_PLAYING);
require(player_open(player.get(), (fixtures + "/audio.wav").c_str()), "failed reporter permits media replacement");
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_audio; },
"replacement remains functional with disabled reporter");
require(!player_shutdown(player.get(), error) && error[0], "final reporting failure remains observable");
player.reset();
[[NSFileManager defaultManager] removeItemAtPath:[NSString stringWithUTF8String:temporary] error:nullptr];
std::puts("PASS native reporting: acknowledged initial resume/audio-only/live eligibility/failed+cancelled opens/final drain/write-failure playback+seek+replacement continuity");
}
int main(int argc, char **argv) {
@autoreleasepool {
require(argc == 3, "loopback base URL and local fixture directory required");
std::string base = argv[1], fixtures = argv[2];
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
[NSApplication sharedApplication];
reporting(base, fixtures);
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
require(bool(player), "player creation");
require(player_set_volume(player.get(), .37f), "set persistent network playback gain");
@@ -510,11 +696,14 @@ int main(int argc, char **argv) {
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
window.contentView = view;
[window orderBack:nil];
chunkedFetch(player.get(), base, false);
chunkedFetch(player.get(), base, true);
transport(player.get(), view, base, false);
transport(player.get(), view, base, true);
frameSteps(player.get(), view, base);
nativeHLS(player.get(), view, base);
liveAndReplacement(player.get(), base, fixtures);
cachedWindow(player.get(), base);
bufferCoverage(player.get(), base);
cancellation(player.get(), base, fixtures);
failures(player.get(), base);
+20 -1
View File
@@ -20,7 +20,9 @@ struct PlaybackProgressChecks {
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: file)
let youtube = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
var progress = PlaybackProgress(defaults: defaults)
let progress = PlaybackProgress(defaults: defaults)
let independent = PlaybackProgress(defaults: UserDefaults(suiteName: suite)!)
let independentFile = directory.appendingPathComponent("independent.mp4")
progress.save(123.25, for: file)
progress.save(18, for: otherFile)
progress.save(9.5, for: youtube)
@@ -29,12 +31,20 @@ struct PlaybackProgressChecks {
require(reopened.position(for: alias) == 123.25, "symlink and original file share progress")
require(reopened.position(for: otherFile) == 18, "local files keep separate positions")
require(reopened.position(for: youtube) == 9.5, "canonical YouTube URL persists independently of local files")
independent.save(27, for: independentFile)
let shared = PlaybackProgress(defaults: defaults)
require(shared.position(for: file) == 123.25 && shared.position(for: youtube) == 9.5,
"an independently initialized player cannot overwrite another video's bookmarks")
require(shared.position(for: independentFile) == 27,
"independent player progress is also retained")
progress.save(nil, for: alias)
let completed = PlaybackProgress(defaults: defaults)
require(completed.position(for: file) == nil, "completion clears progress through either file identity")
require(completed.position(for: otherFile) == 18 && completed.position(for: youtube) == 9.5,
"clearing one video preserves other videos")
require(completed.position(for: independentFile) == 27,
"clearing progress in one player preserves a different player's bookmark")
for position in [Double.nan, Double.infinity, -Double.infinity, -1, 0] {
progress.save(position, for: file)
require(PlaybackProgress(defaults: defaults).position(for: file) == nil,
@@ -44,6 +54,15 @@ struct PlaybackProgressChecks {
defer { isolated.removePersistentDomain(forName: suite + ".isolated") }
require(PlaybackProgress(defaults: isolated).position(for: youtube) == nil,
"injected preference domains remain isolated")
isolated.set([file.standardizedFileURL.absoluteString: 42.5, youtube.absoluteString: 15],
forKey: "playbackPositions")
let migrated = PlaybackProgress(defaults: isolated)
require(migrated.position(for: alias) == 42.5 && migrated.position(for: youtube) == 15,
"existing local and YouTube bookmarks survive the shared-history migration")
migrated.save(43, for: file)
let migratedAgain = PlaybackProgress(defaults: isolated)
require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15,
"opening another player preserves both migrated bookmarks and newer progress")
print("Playback progress checks passed")
}
}
+60 -4
View File
@@ -13,12 +13,17 @@ struct PlayerCommandChecks {
let movie = directory.appendingPathComponent("video with spaces.mp4")
let optionFile = directory.appendingPathComponent("--help")
let colonFile = directory.appendingPathComponent("part:1.mp4")
for file in [movie, optionFile, colonFile] { try Data().write(to: file) }
let instanceFile = directory.appendingPathComponent("--new-instance")
for file in [movie, optionFile, colonFile, instanceFile] { try Data().write(to: file) }
func opened(_ arguments: [String]) throws -> URL? {
guard case .launch(let url) = try PlayerCommand.parse(arguments, directory: directory) else {
func opened(_ arguments: [String], newInstance: Bool = false, noConfig: Bool = false,
progressFile: URL? = nil) throws -> URL? {
guard case .launch(let url, let independent, let bypass, let report) = try PlayerCommand.parse(arguments, directory: directory) else {
fatalError("FAIL: expected a launch command")
}
require(independent == newInstance, "launch mode preserves default reuse or explicit independence")
require(bypass == noConfig, "configuration loading defaults on and can be explicitly bypassed")
require(report == progressFile, "reporting destination preserves launch scope and resolves against invoking directory")
return url
}
require(try opened([movie.lastPathComponent]) == movie, "relative paths resolve against invocation directory, preserving spaces")
@@ -27,10 +32,61 @@ struct PlayerCommandChecks {
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
"independent local launches preserve relative paths and spaces")
require(try opened(["--new-instance", "--url", canonical.absoluteString], newInstance: true) == canonical,
"independent explicit YouTube launches retain canonical resume identity")
require(try opened(["--new-instance", "https://youtu.be/jNQXAC9IVRw"], newInstance: true) == canonical,
"independent positional YouTube launches retain canonical resume identity")
require(try opened(["--new-instance", "--", "--help"], newInstance: true) == optionFile,
"independent launch preserves the filename terminator")
require(try opened(["--", "--new-instance"]) == instanceFile,
"an escaped instance flag is a filename, not a launch option")
require(try opened(["--no-config"], noConfig: true) == nil, "configuration bypass can omit media")
require(try opened(["--no-config", movie.path], noConfig: true) == movie,
"configuration bypass retains local media delivery")
require(try opened(["--new-instance", "--no-config", "--url", canonical.absoluteString],
newInstance: true, noConfig: true) == canonical,
"independent bypass retains explicit YouTube identity")
require(try opened(["--no-config", "--new-instance", "--", "--help"],
newInstance: true, noConfig: true) == optionFile, "launch options work in either order")
guard case .help = try PlayerCommand.parse(["--no-config", "--new-instance", "--help"]) else {
fatalError("FAIL: help with launch options must not launch a player")
}
guard case .help = try PlayerCommand.parse(["--new-instance", "--help"]) else {
fatalError("FAIL: help must not launch an independent player")
}
guard case .printDefaultConfig = try PlayerCommand.parse(["--default-config"]) else {
fatalError("FAIL: printing defaults must not launch a player")
}
let report = directory.appendingPathComponent("session with spaces.jsonl")
require(try opened(["--new-instance", "--url", canonical.absoluteString, "--progress-file", report.path],
newInstance: true, progressFile: report) == canonical,
"reporting after media arguments retains canonical YouTube identity")
require(try opened(["--progress-file", report.lastPathComponent, "--new-instance", "--no-config", "--url", canonical.absoluteString],
newInstance: true, noConfig: true, progressFile: report) == canonical,
"relative report path is made absolute before GUI forwarding")
let reportFlagFile = directory.appendingPathComponent("--progress-file")
try Data().write(to: reportFlagFile)
require(try opened(["--", "--progress-file"]) == reportFlagFile,
"escaped reporting option remains a filename")
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"]] {
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"],
["--new-instance", "--new-instance"], ["--new-instance", "--url"],
["--new-instance", "--"], ["--new-instance", movie.path, movie.path],
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
["--default-config", movie.path], ["--default-config", "--default-config"],
["--print-default-config"],
["--progress-file", report.path],
["--new-instance", "--url", canonical.absoluteString, "--progress-file"],
["--new-instance", "--progress-file", ""],
["--new-instance", "--progress-file", report.path, "--progress-file", report.path],
["--new-instance", "--progress-file", report.path, "--help"],
["--new-instance", "--progress-file", report.path, "--default-config"],
["--no-config", "--new-instance", "--no-config"]] {
do {
_ = try PlayerCommand.parse(arguments, directory: directory)
fatalError("FAIL: accepted an invalid command")
+304
View File
@@ -0,0 +1,304 @@
#include "../Sources/Core/progress_reporter.hpp"
#import <Foundation/Foundation.h>
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <limits>
#include <signal.h>
#include <spawn.h>
#include <string>
#include <stdexcept>
#include <sys/resource.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
extern char **environ;
using playback::ProgressReporter;
static const std::string firstURL = "https://www.youtube.com/watch?v=AAAAAAAAAAA";
static const std::string secondURL = "https://www.youtube.com/watch?v=BBBBBBBBBBB";
static void require(bool condition, const char *message) {
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
}
static PlayerState sample(PlayerStatus status, double position, double duration = 100) {
PlayerState state{};
state.status = status;
state.position = position;
state.duration = duration;
state.has_audio = state.has_video = state.can_seek = true;
return state;
}
static NSString *nativePath(const std::string &path) {
return [NSString stringWithUTF8String:path.c_str()];
}
static NSArray<NSDictionary *> *records(const std::string &path) {
NSString *text = [NSString stringWithContentsOfFile:nativePath(path)
encoding:NSUTF8StringEncoding error:nullptr];
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", @"media_url", @"position_seconds",
@"duration_seconds", @"completion_eligible"]];
for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
options:0 error:nullptr];
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
NSDictionary *row = object;
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == 1,
"schema version is numeric one");
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "canonical consumer identity");
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
std::isfinite([row[@"position_seconds"] doubleValue]) &&
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
id duration = row[@"duration_seconds"];
require(duration == [NSNull null] || ([duration isKindOfClass:[NSNumber class]] &&
std::isfinite([duration doubleValue]) && [duration doubleValue] > 0), "duration is positive or null");
require(CFGetTypeID((__bridge CFTypeRef)row[@"completion_eligible"]) == CFBooleanGetTypeID(),
"eligibility is a JSON boolean");
[result addObject:row];
}
return result;
}
static void expect(NSDictionary *row, NSString *event, const std::string &url,
double position, bool eligible, double duration = 100) {
require([row[@"event"] isEqual:event] && [row[@"media_url"] isEqual:nativePath(url)] &&
[row[@"position_seconds"] doubleValue] == position &&
[row[@"completion_eligible"] boolValue] == eligible, "event has the acknowledged consumer snapshot");
require(duration > 0 && std::isfinite(duration) ? [row[@"duration_seconds"] doubleValue] == duration :
row[@"duration_seconds"] == [NSNull null], "duration retains precision or is null");
}
static bool rejected(const std::string &path) {
try { ProgressReporter reporter(path); }
catch (const std::exception &error) { return error.what()[0] != '\0'; }
return false;
}
// An actual OS write failure, isolated so the resource limit cannot affect the parent.
static int writeFailure(const std::string &path) {
require(signal(SIGXFSZ, SIG_IGN) != SIG_ERR, "ignore file-size-limit signal");
struct rlimit limit{1, 1};
require(setrlimit(RLIMIT_FSIZE, &limit) == 0, "install deterministic one-byte file limit");
ProgressReporter reporter(path, [] { return 0.; });
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_PLAYING, 1));
reporter.finish();
auto error = reporter.error();
require(!error.empty(), "OS write failure is persistently reported without throwing");
reporter.begin(secondURL);
reporter.observe(sample(PLAYER_ENDED, 100));
reporter.finish();
require(reporter.error() == error, "failure remains available after repeated finish and observations");
struct stat info{};
require(stat(path.c_str(), &info) == 0 && info.st_size == 1,
"partial write is retained and failed output stays disabled");
return 0;
}
int main(int argc, char **argv) {
if (argc == 3 && std::string(argv[1]) == "--write-failure") return writeFailure(argv[2]);
@autoreleasepool {
char directory[] = "/tmp/terebi-progress-reporting-XXXXXX";
require(mkdtemp(directory) != nullptr, "create isolated output directory");
const std::string root(directory);
double now = 100;
const auto path = root + "/cadence.jsonl";
{
ProgressReporter reporter(path, [&] { return now; });
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_OPENING, 99));
reporter.observe(sample(PLAYER_ERROR, 99));
auto pending = sample(PLAYER_PAUSED, 99);
pending.seek_pending = true;
reporter.observe(pending);
reporter.observe(sample(PLAYER_PLAYING, 1.2345678901234567));
now = 104.999;
reporter.observe(sample(PLAYER_PLAYING, 4));
now = 105;
reporter.observe(sample(PLAYER_PLAYING, 6.25));
now = 106;
pending = sample(PLAYER_PLAYING, 99);
pending.seek_pending = true;
reporter.observe(pending);
now = 107;
reporter.observe(sample(PLAYER_PLAYING, 95));
now = 108;
reporter.observe(sample(PLAYER_PLAYING, 96));
reporter.observe(sample(PLAYER_PAUSED, 96.5));
pending = sample(PLAYER_PAUSED, 50);
pending.seek_pending = true;
reporter.observe(pending);
reporter.observe(sample(PLAYER_PAUSED, 50));
now = 200;
reporter.observe(sample(PLAYER_PAUSED, 97));
reporter.observe(sample(PLAYER_PAUSED, std::numeric_limits<double>::quiet_NaN()));
pending.position = 99;
reporter.observe(pending);
reporter.closeMedia();
reporter.closeMedia();
reporter.finish();
reporter.finish();
require(reporter.error().empty(), "normal output drains without errors");
}
auto rows = records(path);
require(rows.count == 6, "initial, exact period, acknowledged seek, pause, backward seek, and one close only");
expect(rows[0], @"progress", firstURL, 1.2345678901234567, true);
expect(rows[1], @"progress", firstURL, 6.25, true);
expect(rows[2], @"progress", firstURL, 95, true);
expect(rows[3], @"progress", firstURL, 96.5, true);
expect(rows[4], @"progress", firstURL, 50, true);
expect(rows[5], @"closed", firstURL, 97, true);
const auto identityPath = root + "/identity.jsonl";
now = 0;
{
ProgressReporter reporter(identityPath, [&] { return now; });
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_PLAYING, 10));
now = 1;
reporter.observe(sample(PLAYER_PLAYING, 96));
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_OPENING, 0));
now = 2;
reporter.observe(sample(PLAYER_PAUSED, 96));
now = 2.1;
reporter.observe(sample(PLAYER_PLAYING, 20));
now = 3;
reporter.observe(sample(PLAYER_PLAYING, 96));
reporter.begin(secondURL);
reporter.observe(sample(PLAYER_PLAYING, 90));
now = 3.1;
reporter.observe(sample(PLAYER_PLAYING, 96));
now = 4;
reporter.observe(sample(PLAYER_ENDED, 100));
reporter.observe(sample(PLAYER_ENDED, 0));
reporter.begin("");
reporter.observe(sample(PLAYER_PLAYING, 10));
reporter.finish();
}
rows = records(identityPath);
require(rows.count == 8, "quality reopen preserves threshold while replacement resets identity and closes each source");
expect(rows[0], @"progress", firstURL, 10, true);
expect(rows[1], @"progress", firstURL, 96, true);
expect(rows[2], @"progress", firstURL, 96, true);
expect(rows[3], @"closed", firstURL, 96, true);
expect(rows[4], @"progress", secondURL, 90, true);
expect(rows[5], @"progress", secondURL, 96, true);
expect(rows[6], @"ended", secondURL, 100, true);
expect(rows[7], @"closed", secondURL, 100, true);
const auto terminalPath = root + "/terminal.jsonl";
now = 0;
{
ProgressReporter reporter(terminalPath, [&] { return now; });
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_PLAYING, 94));
now = 1;
reporter.observe(sample(PLAYER_PAUSED, 95));
reporter.closeMedia();
reporter.begin(secondURL);
reporter.observe(sample(PLAYER_PLAYING, 94));
reporter.observe(sample(PLAYER_ENDED, 95));
reporter.finish();
}
rows = records(terminalPath);
require(rows.count == 6, "pause/ended threshold crossings do not add redundant progress events");
expect(rows[1], @"progress", firstURL, 95, true);
expect(rows[2], @"closed", firstURL, 95, true);
expect(rows[4], @"ended", secondURL, 95, true);
expect(rows[5], @"closed", secondURL, 95, true);
const auto eligibilityPath = root + "/eligibility.jsonl";
{
ProgressReporter reporter(eligibilityPath, [] { return 0.; });
for (int scenario = 0; scenario < 7; ++scenario) {
auto state = sample(PLAYER_PAUSED, 97);
if (scenario == 0) state.duration = 0;
if (scenario == 1) state.duration = -1;
if (scenario == 2) state.duration = std::numeric_limits<double>::infinity();
if (scenario == 3) state.duration = std::numeric_limits<double>::quiet_NaN();
if (scenario == 5) state.can_seek = false;
if (scenario == 6) state.has_video = false;
reporter.begin(firstURL, scenario == 4);
reporter.observe(state);
reporter.closeMedia();
}
reporter.finish();
}
rows = records(eligibilityPath);
require(rows.count == 14, "every ineligible source has an initial and terminal snapshot");
for (NSUInteger i = 0; i < rows.count; ++i)
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
require(ProgressReporter::validMediaURL(firstURL) &&
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted");
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA",
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA",
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1",
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
const auto clockFailurePath = root + "/clock-failure.jsonl";
{
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_PLAYING, 1));
reporter.closeMedia();
reporter.finish();
}
require([[NSData dataWithContentsOfFile:nativePath(clockFailurePath)] length] == 0,
"a reporting exception cannot emit later close records");
const auto privatePath = root + "/private.jsonl";
mode_t oldMask = umask(0777);
{ ProgressReporter reporter(privatePath); umask(oldMask); reporter.finish(); }
struct stat info{};
require(stat(privatePath.c_str(), &info) == 0 && S_ISREG(info.st_mode) &&
(info.st_mode & 0777) == 0600 && info.st_uid == geteuid(), "new destination is regular and exactly owner-private despite umask");
const auto existingPath = root + "/existing.jsonl";
NSData *sentinel = [@"unchanged" dataUsingEncoding:NSUTF8StringEncoding];
require([sentinel writeToFile:nativePath(existingPath) atomically:NO], "create existing destination sentinel");
require(rejected(existingPath), "existing destination fails exclusive initialization");
const auto linkPath = root + "/symlink.jsonl";
require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization");
require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination");
const auto concurrentPath = root + "/concurrent.jsonl";
std::atomic<int> ready{0}, successes{0}, failures{0};
std::atomic<bool> start{false};
auto contender = [&] {
ready.fetch_add(1);
while (!start.load()) std::this_thread::yield();
try { ProgressReporter reporter(concurrentPath); ++successes; reporter.finish(); }
catch (const std::exception &) { ++failures; }
};
std::thread left(contender), right(contender);
while (ready.load() != 2) std::this_thread::yield();
start.store(true);
left.join(); right.join();
require(successes == 1 && failures == 1, "concurrent initialization admits exactly one destination owner");
const auto failedPath = root + "/failed.jsonl";
char *childArguments[] = {argv[0], const_cast<char *>("--write-failure"),
const_cast<char *>(failedPath.c_str()), nullptr};
pid_t child = 0;
require(posix_spawnp(&child, argv[0], nullptr, nullptr, childArguments, environ) == 0, "launch isolated write-failure subprocess");
int status = 0;
require(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0,
"resource-limit failure remains nonfatal and output stays disabled");
require([[NSData dataWithContentsOfFile:nativePath(failedPath)] length] == 1, "forced failure permits only its final partial line");
require([[NSFileManager defaultManager] removeItemAtPath:nativePath(root) error:nullptr], "remove isolated check files");
std::puts("JSONL playback progress reporting passed");
}
return 0;
}
+43 -12
View File
@@ -24,6 +24,7 @@ class _Gate:
self.requests = 0
self.ranges = 0
self.bytes = 0
self.connections = set()
class StreamingServer:
@@ -32,6 +33,9 @@ class StreamingServer:
headers = {
"av.mp4": "combined",
"streaming.mp4": "combined",
"cache.mp4": "combined",
"quality-1080.mp4": "combined",
"quality-720.mp4": "combined",
"frame-step.mp4": "combined",
"video.mp4": "video",
"frame-step-video.mp4": "video",
@@ -57,10 +61,14 @@ class StreamingServer:
pass # Media URLs/headers never appear in logs.
def do_GET(self):
owner._get(self)
def handle(self):
try:
owner._get(self)
super().handle()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
pass # The engine intentionally cancels active media connections.
# Cancellation can close a body read or an idle keep-alive connection.
self.close_connection = True
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.http.daemon_threads = True
@@ -134,6 +142,19 @@ class StreamingServer:
self.prefixes[key] = {kind: min(end, first_missing[kind]) for kind, end in ends.items()}
return self.prefixes[key]
def _timestamp_offset(self, name: str, seconds: float) -> int:
probe = subprocess.run(
["ffprobe", "-v", "error", "-show_packets", "-show_format",
"-show_entries", "format=start_time:packet=pts_time,pos", "-of", "json",
str(self.fixtures / name)],
capture_output=True, text=True, check=True, timeout=30,
)
metadata = json.loads(probe.stdout)
origin = float(metadata.get("format", {}).get("start_time", 0))
return min(int(packet["pos"]) for packet in metadata["packets"]
if "pos" in packet and "pts_time" in packet
and float(packet["pts_time"]) - origin >= seconds)
def _get(self, request):
parsed = urlsplit(request.path)
parts = unquote(parsed.path).strip("/").split("/")
@@ -147,19 +168,20 @@ class StreamingServer:
gate = self.gates.get(parts[2])
if gate is None:
body = {"entered": False, "released": False, "waiting": 0, "requests": 0,
"ranges": 0, "bytes": 0, "total": 0, "prefix_ends": {}}
"ranges": 0, "bytes": 0, "total": 0, "connections": 0, "prefix_ends": {}}
else:
if parts[1] == "release":
gate.release.set()
body = {"entered": gate.entered.is_set(), "released": gate.release.is_set(),
"waiting": gate.waiting, "requests": gate.requests, "ranges": gate.ranges,
"bytes": gate.bytes, "total": gate.total, "prefix_ends": gate.prefix_ends}
"bytes": gate.bytes, "total": gate.total, "connections": len(gate.connections),
"prefix_ends": gate.prefix_ends}
self._reply(request, 200, json.dumps(body).encode())
return
mode = parts[0]
token = None
if len(parts) == 3 and mode in ("stall", "stall-open"):
if len(parts) == 3 and mode in ("stall", "stall-open", "chunked-media"):
token, name = parts[1:]
if not re.fullmatch(r"[A-Za-z0-9_-]+", token):
self._reply(request, 400, b"Invalid gate token", "text/plain")
@@ -206,20 +228,28 @@ class StreamingServer:
if token is not None:
query = parse_qs(parsed.query)
try:
gate_at = int(query.get("after", ["131072"])[0]) if mode == "stall" else start
except ValueError:
gate_at = (self._timestamp_offset(name, float(query["after_seconds"][0]))
if mode in ("stall", "chunked-media") and "after_seconds" in query else
int(query.get("after", ["131072"])[0]) if mode in ("stall", "chunked-media") else start)
except (ValueError, OverflowError):
self._reply(request, 400, b"Invalid gate offset", "text/plain")
return
with self.lock:
if token not in self.gates:
prefix = self._prefix_ends(name, gate_at) if mode == "stall" else {}
prefix = self._prefix_ends(name, gate_at) if mode in ("stall", "chunked-media") else {}
self.gates[token] = _Gate(size, prefix)
gate = self.gates[token]
gate.requests += 1
gate.connections.add(request.client_address)
gate.ranges += int(range_header is not None)
# Only the original prefix connection is gated: a seek can create a new
# range request while the old read is still blocked and never released.
should_gate = not gate.claimed and start <= gate_at <= end
# Simulate a CDN that throttles oversized/open-ended ranges, while
# finite requests of at most 10 MiB run at full available speed.
oversized = mode == "chunked-media" and (
range_header is None or not range_header.split("-", 1)[1]
or int(range_header.split("-", 1)[1]) - start + 1 > 10 * 1024 * 1024)
should_gate = (mode != "chunked-media" or oversized) and not gate.claimed and start <= gate_at <= end
if should_gate:
gate.claimed = True
with self.lock:
@@ -231,10 +261,11 @@ class StreamingServer:
request.send_header("Content-Length", str(end - start + 1))
if range_header is not None:
request.send_header("Content-Range", f"bytes {start}-{end}/{size}")
request.send_header("Connection", "close")
persistent = mode == "chunked-media" and parse_qs(parsed.query).get("keep_alive") == ["1"]
request.send_header("Connection", "keep-alive" if persistent else "close")
request.end_headers()
request.wfile.flush()
request.close_connection = True
request.close_connection = not persistent
cursor = start
with path.open("rb") as media:
media.seek(start)
@@ -270,7 +301,7 @@ def main():
parser.add_argument("fixtures", type=Path)
args = parser.parse_args()
required = ("av.mp4", "video.mp4", "audio.wav", "frame-step-video.mp4",
"frame-step-audio.m4a", "streaming.mp4", "network-vod.m3u8")
"frame-step-audio.m4a", "streaming.mp4", "cache.mp4", "network-vod.m3u8")
missing = [name for name in required if not (args.fixtures / name).is_file()]
if missing:
parser.error("Missing streaming fixtures: " + ", ".join(missing))
+105 -1
View File
@@ -1,5 +1,6 @@
import AppKit
import MediaPlayer
import TerebiConfiguration
@main
@MainActor
@@ -22,8 +23,27 @@ struct SystemPlaybackChecks {
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let initialModel = PlayerModel(defaults: defaults)
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
initialModel.setShowsBufferInfo(true)
require(initialModel.showsBufferInfo && initialModel.controlsVisible,
"buffer readout can be enabled before playback and reveals controls")
initialModel.shutdown()
let restoredModel = PlayerModel(defaults: defaults)
require(!restoredModel.showsBufferInfo, "session opt-in does not survive model recreation")
restoredModel.shutdown()
let configuredModel = PlayerModel(defaults: defaults, configuration:
PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false))
require(configuredModel.showsBufferInfo && configuredModel.automaticYouTubeQuality
&& !configuredModel.compactWindow.snappingEnabled, "configuration initializes all three controls")
configuredModel.setShowsBufferInfo(false)
configuredModel.setAutomaticYouTubeQuality(false)
configuredModel.compactWindow.snappingEnabled = true
configuredModel.shutdown()
let model = PlayerModel(defaults: defaults)
defer { model.shutdown() }
require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled,
"session overrides never replace built-in settings")
model.setVolume(0)
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let center = MPNowPlayingInfoCenter.default()
@@ -42,6 +62,27 @@ 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")
let oppositeIntentRevision = model.state.revision
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 acknowledge play before the final pause") {
model.state.revision != oppositeIntentRevision && model.state.status == PLAYER_PLAYING
}
let playingRevision = model.state.revision
until("rapid opposite intents settle to an acknowledged native pause") {
model.state.revision != playingRevision && model.state.status == PLAYER_PAUSED &&
!model.effectivePlaying && center.playbackState == .paused
}
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
"latest pause intent stops the system timeline after native acknowledgement")
let settledPosition = model.state.position
require(!model.perform(.pause) && !model.effectivePlaying,
"explicit pause cannot resume an already paused player")
let seekableState = model.state
var nonseekableState = seekableState
@@ -57,7 +98,7 @@ struct SystemPlaybackChecks {
require(model.seekRelative(1), "relative seek accepted")
until("paused seek acknowledged") {
model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
abs(model.state.position - pausedPosition - 1) < 0.05
abs(model.state.position - settledPosition - 1) < 0.05
}
require(model.seekRelative(-100), "backward boundary seek accepted")
until("backward seek clamps at start") { !model.state.seek_pending && model.state.position == 0 }
@@ -73,12 +114,75 @@ struct SystemPlaybackChecks {
retainedSnapshot.buffered_end = min(retainedSnapshot.duration, retainedSnapshot.position + 1)
model.applyState(retainedSnapshot)
require(model.bufferedRange != nil, "loaded selection exposes retained media")
let retainedRange = model.bufferedRange
model.setShowsBufferInfo(true)
require(model.bufferedRange == retainedRange, "enabling buffer readout preserves retained media")
model.setShowsBufferInfo(false)
require(model.bufferedRange == retainedRange, "disabling buffer readout preserves retained media")
model.closeFile()
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
"accepted close releases system playback immediately")
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
"closed source cannot receive system playback or seek commands")
let localURL = directory.appendingPathComponent("av.mp4")
let progress = PlaybackProgress(defaults: defaults)
let savedPosition = seekableState.duration / 2
progress.save(savedPosition, for: localURL)
var openRevision = model.state.revision
model.open(localURL)
until("local saved position is restored before native autoplay") {
guard model.state.revision != openRevision else { return false }
if model.state.status == PLAYER_PLAYING && !model.state.seek_pending {
require(model.state.position >= savedPosition,
"local autoplay never acknowledges playback before the saved position")
return model.state.position < savedPosition + 0.5
}
require(progress.position(for: localURL) == savedPosition,
"pre-restoration snapshots cannot overwrite saved history")
return false
}
require(model.setPlayback(playing: false), "restored local playback can pause")
until("restored local pause acknowledged") { model.state.status == PLAYER_PAUSED && !model.state.seek_pending }
let restoredPosition = model.state.position
model.closeFile()
require(progress.position(for: localURL) == restoredPosition,
"close persists acknowledged restored position")
openRevision = model.state.revision
model.open(localURL)
until("close and reopen resumes the persisted native position") {
guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending else { return false }
require(model.state.position >= restoredPosition,
"reopened local autoplay starts at acknowledged persisted progress")
return model.state.position < restoredPosition + 0.5
}
model.closeFile()
let invalidPosition = seekableState.duration + 1
progress.save(invalidPosition, for: localURL)
openRevision = model.state.revision
model.open(localURL)
until("duration-exceeded bookmark is discarded and native playback advances") {
model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && model.state.position > 0.1 && model.state.position < savedPosition
}
require((progress.position(for: localURL) ?? 0) < seekableState.duration,
"duration-exceeded bookmark is removed from exposed history")
model.closeFile()
let replacementPosition = progress.position(for: localURL)!
require(replacementPosition > 0 && replacementPosition < savedPosition,
"close replaces invalid history with acknowledged playback progress")
openRevision = model.state.revision
model.open(localURL)
until("reopening no longer repeats a duration-exceeded restoration") {
guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending else { return false }
require(model.state.position >= replacementPosition,
"replacement bookmark restores before reopened autoplay")
return model.state.position < replacementPosition + 0.5
}
model.closeFile()
model.open(directory.appendingPathComponent("hdr.mkv"))
until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled,
+60 -15
View File
@@ -9,17 +9,21 @@ DEBUG_FLAGS ?= -g
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
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o
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
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a
CONFIG_FLAGS := -I $(CONFIG_BIN) -I $(CONFIG_BIN)/Modules $(CONFIG_LIBRARY)
APP := $(BUILD_DIR)/terebi.app
PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media check-startup
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist
.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt
check-dependencies:
@command -v pkg-config >/dev/null 2>&1 || { printf '%s\n' 'Error: pkg-config is required to build terebi. Run: brew install ffmpeg pkg-config' >&2; exit 1; }
@@ -39,16 +43,29 @@ install: release
$(BUILD_DIR):
mkdir -p "$@"
$(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
Package.resolved: Package.swift
xcrun swift package --scratch-path $(CONFIG_BUILD) resolve
$(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 -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 Sources/Core/progress_reporter.hpp Makefile | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
$(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies
$(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
mkdir -p "$(APP)/Contents/Frameworks"
rm -f "$(APP)/Contents/MacOS/terebi-player"
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -Xlinker -install_name -Xlinker @rpath/libterebi-player.dylib -o $@
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -Xlinker -install_name -Xlinker @rpath/libterebi-player.dylib -o $@
$(MANIFEST): $(PLAYER) Makefile
mkdir -p "$(APP)/Contents/Resources"
@@ -70,7 +87,7 @@ $(APP)/Contents/Info.plist: Resources/Info.plist
$(BUILD_DIR)/terebi: $(CLI_SOURCES) $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist Makefile | check-dependencies
rm -f "$@"
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) -framework AppKit -o $@
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@
run: all
open "$(APP)"
@@ -78,7 +95,7 @@ run: all
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media check-startup
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies
@@ -90,12 +107,13 @@ check-fixtures: | check-dependencies
# Literal frame PTS: 0,.1,.2,.3,.4,.65,.9,1.15,1.4,1.5,1.6,1.7; reordered B-frames across short GOPs.
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=10:duration=1.2 -f lavfi -i sine=frequency=440:sample_rate=44100:duration=2 -vf "settb=1/1000,setpts='if(lt(N,5),N*100,if(lt(N,9),400+(N-4)*250,1400+(N-8)*100))'" -fps_mode vfr -enc_time_base:v 1:1000 -c:v mpeg4 -g 4 -bf 2 -video_track_timescale 1000 -c:a aac $(BUILD_DIR)/fixtures/frame-step.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 $(BUILD_DIR)/fixtures/hdr.mkv
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -q:v 2 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/cache.mp4
$(BUILD_DIR)/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
$(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies
$(CXX) $(CXXFLAGS) $< $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@
$(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies
$(CXX) $(CXXFLAGS) $< $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@
check-audio: $(BUILD_DIR)/audio-continuity-check
mkdir -p $(BUILD_DIR)/fixtures
@@ -117,12 +135,33 @@ $(BUILD_DIR)/compact-window-check: Checks/compact-window.swift Sources/App/Compa
check-window: $(BUILD_DIR)/compact-window-check
$(BUILD_DIR)/compact-window-check
$(BUILD_DIR)/configuration-check: Checks/configuration.swift $(CONFIG_LIBRARY) Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $< $(CONFIG_FLAGS) -o $@
check-config: $(BUILD_DIR)/configuration-check
$(BUILD_DIR)/configuration-check
$(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
check-youtube: $(BUILD_DIR)/youtube-resolver-check
$(BUILD_DIR)/youtube-resolver-check
$(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@
check-quality: $(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-adaptive: $(BUILD_DIR)/automatic-quality-playback-check
mkdir -p $(BUILD_DIR)/fixtures
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=blue:size=1920x1080:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-1080.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=green:size=1280x720:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-720.mp4
python3 Checks/automatic-quality.py $(BUILD_DIR)/automatic-quality-playback-check $(BUILD_DIR)/fixtures
$(BUILD_DIR)/network-playback-check: Checks/network-playback.mm $(OBJECTS) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
@@ -139,6 +178,12 @@ $(BUILD_DIR)/playback-progress-check: Checks/playback-progress.swift Sources/App
check-progress: $(BUILD_DIR)/playback-progress-check
$(BUILD_DIR)/playback-progress-check
$(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -fobjc-arc Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp -framework Foundation -o $@
check-reporting: $(BUILD_DIR)/progress-reporting-check
$(BUILD_DIR)/progress-reporting-check
$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
@@ -151,14 +196,14 @@ $(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE)
check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(MANIFEST)
$(BUILD_DIR)/ffmpeg-runtime-check "$(APP)"
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-media: $(BUILD_DIR)/system-playback-check check-fixtures
$(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures
python3 Checks/app-startup.py "$(APP)" "$(BUILD_DIR)/app-startup-check.dylib" "$(BUILD_DIR)/fixtures"
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "95099b9cc8143f6f9e6723cb66f03f98d8152a6a18ba6bb76fcde17633818949",
"pins" : [
{
"identity" : "tomldecoder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/dduan/TOMLDecoder.git",
"state" : {
"revision" : "a2bbd2796fe3064e107de18cb56031052c4fa899",
"version" : "0.4.5"
}
}
],
"version" : 3
}
+12
View File
@@ -0,0 +1,12 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "TerebiConfiguration",
platforms: [.macOS(.v15)],
products: [.library(name: "TerebiConfiguration", type: .static, targets: ["TerebiConfiguration"])],
dependencies: [.package(url: "https://github.com/dduan/TOMLDecoder.git", exact: "0.4.5")],
targets: [.target(name: "TerebiConfiguration", dependencies: [
.product(name: "TOMLDecoder", package: "tomldecoder")
], path: "Sources/Configuration")]
)
+142 -32
View File
@@ -6,6 +6,8 @@ A personal native macOS player whose playback code is small enough to own and mo
Requires macOS 15+, Xcode's command-line tools with Swift 6, and Homebrew FFmpeg/pkg-config:
The first build fetches pinned [TOMLDecoder 0.4.5](https://github.com/dduan/TOMLDecoder/tree/0.4.5) through SwiftPM. It is statically linked into the CLI/player; running the installed app needs no separate TOML runtime. Its MIT notice is included in the app's Resources.
```sh
brew install ffmpeg pkg-config
make
@@ -43,14 +45,79 @@ The declared app executable stays running after preflight and loads `Contents/Fr
.build/terebi 'https://youtu.be/jNQXAC9IVRw'
.build/terebi --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
.build/terebi -- -video.mp4
.build/terebi --new-instance
.build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
.build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' --progress-file "/private/session/progress.jsonl"
.build/terebi --new-instance --no-config "/path/to/video.mp4"
.build/terebi --help
.build/terebi --default-config
```
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`.
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`.
The launcher finds `terebi.app` beside its own executable, following symlinks, and uses macOS application/URL delivery to reuse the running player and its existing window. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Relaunch an older running build once to load the new external-open handling.
The launcher finds `terebi.app` beside its own executable, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share saved progress, but configuration and control overrides belong to each session. Quit all running players after installing this update so every process uses the updated configuration/startup handling.
Help exits with status **0** without opening the GUI or loading FFmpeg. Invalid arguments, unsupported URLs, missing files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface.
Help and `--default-config` exit with status **0** without opening the GUI, reading configuration, or checking/loading FFmpeg. The latter prints complete built-in TOML to stdout, including every named key action and explicit `[]` entries for unbound actions; it uses the player's authoritative defaults rather than your current settings. It accepts no media argument and never creates or writes a config file. Invalid arguments, unsupported URLs, missing media files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**; unreadable or invalid configuration exits **78** with the file path and diagnostic. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface.
Reporting destination preflight failures exit **73** without launching. The GUI still creates the file atomically and reports initialization failures with a native alert; those can occur after the launcher has returned successfully.
## Configuration
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. The complete built-in configuration is:
```toml
[playback]
automatic_quality = false
[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 = []
```
All settings belong to `[playback]`, `[ui]`, or `[keys]`; these sections can appear in any order. Omitted sections use built-in defaults. An assignment belongs to its current TOML section, so place `automatic_quality` beneath `[playback]`, not at the root or under another header.
Omitted settings/actions retain their defaults. A shortcut string or string array **replaces** an action's default bindings; `[]` unbinds it. Keys are case-insensitive logical keys, not physical key positions. Use exact modifiers `cmd`, `ctrl`, `alt`/`option`, and `shift`, joined with `+`; uppercase `P` alone does not mean Shift+P. Named keys are `space`, `escape`, `return`, `tab`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown`, `plus`, and `f1`–`f35`; printable characters such as `,` and `.` also work. OS-reserved Cmd+Tab is rejected.
Bindings name explicit actions, not toggles. The same chord may be shared only by inverse pairs shown above: play/pause, mute/unmute, enter/exit fullscreen or compact, enable/disable always-on-top, show/hide buffer info, and enable/disable snapping or automatic quality. A press performs **one currently applicable action**, including pending playback/window intent; already-satisfied actions do nothing. Give each side different chords for dedicated enable/disable keys. Sharing a chord between unrelated actions, duplicate normalized chords, unknown actions, and malformed shortcuts are errors.
For YouTube, bind `quality_<height>p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. Captions are not implemented, so caption and `toggle_*` action names are rejected.
Behavior/UI values must be TOML booleans. Unknown keys, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes.
Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history remains separate in UserDefaults; legacy UserDefaults settings are no longer read or written.
## YouTube
@@ -64,10 +131,16 @@ Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a Yo
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
Network playback controls show **Buffered: 1.6s ahead** in both normal and compact mode, so short buffers remain visible even on a multi-hour timeline without opening a tooltip. **Buffered: —** means no estimate is available, including during seeks or for live/unknown-duration media. The normal-mode seek bar also highlights the **buffered range** in lighter gray ahead of the playhead, drawn to scale; hover for the buffered-through timestamp, which VoiceOver also includes. This is a conservative playable range constrained by both required audio and video inputs, including decoded/output buffers—not a percentage downloaded or a persistent cache of the whole video. It resets on seeks, quality/source changes, and close. Local playback displays neither indicator. On long videos the shaded segment can be sub-pixel because the retained buffers are bounded; the readout remains legible whenever the controls are visible.
Seekable network playback targets **60 seconds ahead and 30 seconds behind** using retained compressed audio/video packets, with a shared **128 MiB** byte budget. Time targets are best-effort: bandwidth, bitrate, packet/keyframe layout, source availability, and byte pressure can shorten the window. Playback starts promptly and read-ahead continues in the background, including while paused. Cached seeks replay from the preceding usable keyframe without a new HTTP request; seeks outside the retained window refill normally. This is an in-memory sliding cache, not a full-video download or persistent disk cache.
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 `[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.
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `[playback].automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency.
## Resume playback
@@ -76,6 +149,36 @@ Reopening a local video or YouTube video automatically resumes its saved positio
Progress is stored locally in UserDefaults, checkpointed every five seconds of playback progress, and saved on pause, close, replacement, and normal quit. Only authoritative, acknowledged positions are saved—not an unfinished seek or slider drag. Finished videos clear their bookmark and reopen from the beginning; a bookmark beyond the current video's duration also restarts at the beginning. Failed opens preserve existing bookmarks. Live/non-seekable inputs and audio-only media do not record progress.
Each video's bookmark has its own UserDefaults key, so separate player processes cannot erase unrelated bookmarks by rewriting a cached history dictionary. Existing bookmarks migrate automatically from the old dictionary. If two instances play the same video, the most recent checkpoint wins; progress is shared, not a separate bookmark per window.
## Playback progress reporting
Opt in per player session with **`--progress-file PATH`**, which requires **`--new-instance`**. The option may follow `--url URL`; relative report paths resolve against the invoking shell's directory before being forwarded to the GUI. Reporting is disabled without the option and is not a config.toml setting.
Create a unique private directory and supply a **nonexistent** file:
```sh
session=$(mktemp -d /tmp/terebi-progress.XXXXXX)
terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' \
--progress-file "$session/progress.jsonl"
```
The shared C++ backend owns reporting inside the GUI process, independent of the launcher and UserDefaults. It exclusively creates a regular file with owner-only read/write permissions (`0600`), rejecting existing destinations and symlinks. Parent directories must already exist; the caller owns directory/file cleanup. Concurrent players cannot claim the same destination.
Each complete line is a version-1 JSON record:
```json
{"version":1,"event":"progress","media_url":"https://www.youtube.com/watch?v=jNQXAC9IVRw","position_seconds":123.5,"duration_seconds":200.0,"completion_eligible":true}
```
`event` is `progress`, `ended`, or `closed`. Identity is the canonical YouTube webpage URL, never the extracted stream URL. Position is finite, nonnegative native playback state; duration is positive and finite or `null`. Eligibility is false for live/non-seekable inputs, unknown duration, and audio-only media.
The backend reports initial ready state after acknowledging restoration, approximately every five seconds while playing, promptly at the first eligible 95% crossing, on acknowledged pause/seek/frame-step transitions, and at native end, replacement, close, and normal quit. Pending seeks and slider previews are not actual positions. Quality reopenings preserve identity and do not produce `closed`; local-media replacement closes the outgoing YouTube identity and produces no local-media records. Failed or cancelled opens never report the requested video's progress.
Writes are serialized on a separate thread; normal shutdown drains complete records. Write failure disables reporting and surfaces a separate warning without stopping playback. Consumers must tolerate duplicates, incomplete trailing lines, and abrupt termination; a `closed` record is not guaranteed.
Terebi reports facts, not watched status. A consumer such as Tubular decides completion using `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`, for the matching video identity. This is a **position threshold**, not viewing coverage: seeking near the end counts. Window closure or successful launcher exit alone does not.
## Playback UI
The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point frosted titlebar with native traffic lights and file icon, plus a dark frosted 440 × 68-point floating controller. AppKit materials blur the video beneath the chrome; muted icons, an orange volume track, a narrow seek thumb, and zero-padded time labels follow the reference. Shared vertical padding and an optical offset for symbol/slider insets balance the controller's visible top and bottom margins in both play and pause states. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size.
@@ -84,20 +187,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 and remembers its toggle across launches. 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.
@@ -114,18 +206,24 @@ Only implemented actions have controls; there are no placeholder playlist, subti
## Where to change things
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, and live status. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `buffered_start`/`buffered_end` report a conservative retained playable range in normalized seconds, constrained by both tracks; unavailable/local/pending ranges are `0, 0`, and a nonempty range starts at `position` and ends no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes both selected inputs' old timeline state before resuming.
- `Sources/Core/network_input.hpp`: network-only demux readers with bounded encoded packet queues, constant-time timestamp frontiers (conservative for reordered video), asynchronous coordinated seeks, fifteen-second I/O deadlines, and interruption on seek/replacement/close/destruction. Remote input protocols exclude local-file access through manifests.
Playback terms are defined in [CONTEXT.md](CONTEXT.md).
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, live status, canonical `media_url`, and `initial_position`. Initial network restoration completes before reporting ready state. `player_set_progress_file` initializes opt-in reporting before opening media; its errors and `player_get_progress_error` are separate from playback errors. `player_shutdown` stops the worker and drains reporting, returning final reporting errors before handle destruction. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `buffered_start`/`buffered_end` report a conservative retained/playable interval in normalized seconds, constrained by required tracks; unavailable/local/pending ranges are `0, 0`, the start may include reusable history before `position`, and the end is no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded decoded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. Seeks stop output and flush decoder/output state; network inputs replay retained packets or seek/refill on cache misses.
- `Sources/Core/progress_reporter.hpp` and `.cpp`: shared JSONL schema, canonical identity, acknowledged snapshots, eligibility, clock-injected cadence/threshold detection, lifecycle records, and asynchronous file writer. File creation/write uses POSIX facilities; reporting policy is independent of AppKit/Swift.
- `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, YouTube quality switching with in-memory 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/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/TerebiApp.swift`: MainActor `PlayerModel` retains the control interface. Private `PlaybackTransition` owns extraction lifetime, open/seek acknowledgement, playback intent, local restoration, and checkpoint eligibility. `MediaSource`/`MediaChange` feed one opening path for files, YouTube, and quality changes. Native observations checkpoint before consuming local restoration; network restoration and JSONL reporting remain in C++. Panels, controls, visibility, video sizing, and window integration remain presentation.
- `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, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source.
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference.
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle.
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Launch the app without media first to establish its singleton window, then deliver the selected URL; the app owns Apple events to prevent SwiftUI's default external-document routing from replacing that window.
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton 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.
- `Makefile`: compiler/linker commands, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project or additional build dependency.
- `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
@@ -137,21 +235,33 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook.
`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, isolated preference persistence, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent.
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. Retained-range checks cover bounded growth, FFprobe-derived supplied-packet timestamp bounds and B-frame dependencies, both split-track constraints, explicit gate-release refill, forward/backward seek invalidation, shorter-track EOF tails, and clearing on close/error/local replacement. Both network and YouTube checks are included in `make check`.
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`.
`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, and isolated preference domains. It is included in `make check` and does not touch real playback history.
`make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and session-only opt-in/configuration restoration. The quality clock is injected; tests use isolated playback history and muted playback. Both targets are included in `make check`; the native adaptive portion is skipped if yt-dlp is absent. No external network is required.
`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, argument cardinality, unknown options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
Adaptive transition checks also gate a real extraction, cancel it by pausing, and verify that late completion cannot reopen quality or resume paused playback. Failed manual quality changes preserve the original media, position, and prior playing/paused state.
`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, isolated preference domains, independently initialized writers preserving each other's videos, and migration of existing bookmarks. It is included in `make check` and does not touch real playback history.
`make check-reporting` decodes real JSONL files with an injected clock: initial/period boundaries, pending versus acknowledged seeks, threshold/pause/end/close facts, quality/replacement identity, nullable duration and eligibility, exclusive private creation, existing/symlink/concurrent rejection, and isolated OS write failure. `make check-network` also exercises the real backend's acknowledged initial restoration, audio-only/live eligibility, failed/cancelled opens, final drain, and continued playback/seek/replacement after a failed writer. Both are included in `make check`.
`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, `--no-config` alone and combined in either order, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
`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 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 that the playing process retains a real registered app PID and bundle identity—the prerequisite for third-party Now Playing discovery. It is included in `make check`; it does not require Vorssaint, show a playback window, or touch real playback history. Cold Finder/CLI opens, warm local/YouTube opens, saved-position restoration, same-window reuse, and Vorssaint's adapter discovery were additionally smoke-checked through the real GUI.
Playback transition checks wait for native acknowledgement of rapid opposite intents, restore local bookmarks before autoplay, persist/resume the acknowledged position across close/reopen, and discard duration-exceeded bookmarks without preventing playback.
`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.
Startup checks also exercise actual CLI-to-GUI reporting after the launcher exits: two independent private streams, canonical identities, acknowledged resume/seek/pause, quality reopenings, replacement/end/close/quit, failed resolution, local-media exclusions, and existing/dangling-destination preflight errors. Report readers tolerate duplicates and partial trailing lines; watched facts are derived from eligible native positions, not launcher success.
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.
@@ -167,4 +277,4 @@ Forward frame stepping retains the live video decoder cursor instead of seeking
Audio buffering is bounded to one second plus one decoded block (up to four seconds); video is bounded to 64 frames/128 MiB. Extremely interleaved files can drop far-future video frames to avoid starving audio; the ceiling and upgrade path are marked at `queueVideo()`. Device/stream latency is accounted for, but device changes during playback are not handled yet.
Network demux queues are additionally bounded to 64 encoded packets/16 MiB per selected track, including packet side data. Each network reader can hold one pending packet while backpressured. Separate inputs retain one authored timestamp origin and one playback clock; buffering stalls that clock rather than skipping ahead. Network reads/seeks have fifteen-second deadlines; there is no automatic reconnection or signed-URL refresh.
Network inputs retain compressed packets within a shared 128 MiB budget, targeting sixty seconds ahead and thirty seconds behind for seekable media. Eviction can shorten history under byte pressure, and keyframe/audio-preroll requirements affect the usable rewind window. Decoded video/audio buffers remain separately bounded. Each network reader can hold one transient demux packet while backpressured. Separate inputs retain one authored timestamp origin and one playback clock; buffering stalls that clock rather than skipping ahead. Live/non-seekable sources do not promise a rewind window or a minute of future live media. Network reads/seeks have fifteen-second deadlines; there is no automatic reconnection or signed-URL refresh.
+95
View File
@@ -0,0 +1,95 @@
import Foundation
// The caller supplies monotonic seconds, keeping the policy independent of timers and extraction.
struct AutomaticQuality {
struct Sample {
var enabled = true
var network = true
var playing = true
var opening = false
var seeking = false
var live = false
var buffering = false
var position: Double
var duration: Double
var ahead: Double?
var height: Int?
var heights: [Int]
}
private var lowSince: Double?
private var healthySince: Double?
private var stallSince: Double?
private var lastPosition: Double?
private var lastProgress: Double?
private var notBefore = -Double.infinity
mutating func resetHealth() {
lowSince = nil
healthySince = nil
stallSince = nil
lastPosition = nil
lastProgress = nil
}
mutating func extractionFailed(at now: Double) {
resetHealth()
notBefore = max(notBefore, now + 120)
}
mutating func choose(_ sample: Sample, at now: Double) -> Int? {
guard sample.enabled, sample.network, sample.playing, !sample.opening, !sample.seeking,
sample.position.isFinite, let height = sample.height, height > 0 else {
resetHealth()
return nil
}
guard !sample.heights.isEmpty,
sample.live || !sample.duration.isFinite || sample.duration <= 0 ||
sample.duration - sample.position > 15 else {
resetHealth()
return nil
}
if let lastPosition, sample.position < lastPosition - 0.1 {
resetHealth()
}
if lastPosition == nil || sample.position > lastPosition! + 0.01 {
lastPosition = sample.position
lastProgress = now
}
let stalled = sample.buffering || now - (lastProgress ?? now) >= 3
let ahead = sample.ahead.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
// Live packet timestamps are not a reliable reserve estimate.
let knownReserve = !sample.live && ahead != nil
if stalled {
stallSince = stallSince ?? now
} else {
stallSince = nil
}
if knownReserve && ahead! < 4 {
lowSince = lowSince ?? now
} else {
lowSince = nil
}
if !stalled && (!knownReserve || ahead! >= 20) {
healthySince = healthySince ?? now
} else {
healthySince = nil
}
guard now >= notBefore else { return nil }
let downshift = stallSince.map({ now - $0 >= 3 }) == true || lowSince.map({ now - $0 >= 6 }) == true
let upshift = healthySince.map({ now - $0 >= (knownReserve ? 60 : 120) }) == true
guard height > 1080 || downshift || upshift else { return nil }
var target: Int?
for available in sample.heights where available > 0 && available <= 1080 {
if height > 1080 || downshift {
if available < height && available > (target ?? 0) { target = available }
} else if available > height && available < (target ?? Int.max) {
target = available
}
}
guard let target else { return nil }
notBefore = now + 30
resetHealth()
return target
}
}
+2 -6
View File
@@ -9,7 +9,6 @@ final class CompactWindowController: NSObject {
var snappingEnabled: Bool {
didSet {
guard snappingEnabled != oldValue else { return }
defaults.set(snappingEnabled, forKey: "compactSnappingEnabled")
onChange?()
}
}
@@ -45,7 +44,6 @@ final class CompactWindowController: NSObject {
private enum FullScreenTransition { case entering, exiting }
private let defaults: UserDefaults
private weak var window: NSWindow?
private var snapshot: Snapshot?
private var fullScreenTransition: FullScreenTransition?
@@ -57,10 +55,8 @@ final class CompactWindowController: NSObject {
private var videoAspect = 16.0 / 9.0
private var fittedAspect: Double?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: ["compactSnappingEnabled": true])
snappingEnabled = defaults.bool(forKey: "compactSnappingEnabled")
init(snappingEnabled: Bool = true) {
self.snappingEnabled = snappingEnabled
super.init()
}
+14 -8
View File
@@ -2,28 +2,34 @@ import Foundation
struct PlaybackProgress {
private let defaults: UserDefaults
private var positions: [String: Double]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
positions = defaults.dictionary(forKey: "playbackPositions") as? [String: Double] ?? [:]
// Migrate the old shared dictionary once; separate keys prevent cross-player lost updates.
if let positions = defaults.dictionary(forKey: "playbackPositions") as? [String: Double] {
for (identity, position) in positions where position.isFinite && position > 0 {
let key = "playbackPosition:" + identity
if defaults.object(forKey: key) == nil { defaults.set(position, forKey: key) }
}
defaults.removeObject(forKey: "playbackPositions")
}
}
func position(for url: URL) -> Double? {
guard let position = positions[key(for: url)], position.isFinite, position > 0 else { return nil }
guard let position = defaults.object(forKey: key(for: url)) as? Double,
position.isFinite, position > 0 else { return nil }
return position
}
mutating func save(_ position: Double?, for url: URL) {
func save(_ position: Double?, for url: URL) {
let key = key(for: url)
let position = position.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
guard positions[key] != position else { return }
positions[key] = position
defaults.set(positions, forKey: "playbackPositions")
guard (defaults.object(forKey: key) as? Double) != position else { return }
defaults.set(position, forKey: key)
}
private func key(for url: URL) -> String {
// YouTube callers supply the canonical webpage URL, never an expiring media URL.
return url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString
return "playbackPosition:" + (url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString)
}
}
+275
View File
@@ -0,0 +1,275 @@
import AppKit
import SwiftUI
import TerebiConfiguration
extension KeyChord {
// Key codes identify native navigation keys; printable keys follow the active keyboard layout.
private static let nativeKeys: [UInt16: String] = [
36: "return", 48: "tab", 49: "space", 51: "backspace", 53: "escape",
76: "return", 115: "home", 116: "pageup", 117: "delete", 119: "end",
121: "pagedown", 123: "left", 124: "right", 125: "down", 126: "up"
]
private static let functionKeys: [UInt32: String] = Dictionary(uniqueKeysWithValues:
(1...35).map { (UInt32(NSF1FunctionKey) + UInt32($0 - 1), "f\($0)") })
static func from(_ event: NSEvent) -> KeyChord? {
guard event.type == .keyDown,
!event.modifierFlags.contains(.function) || nativeKeys[event.keyCode] != nil ||
event.charactersIgnoringModifiers?.unicodeScalars.first.map({
$0.value >= UInt32(NSF1FunctionKey) && $0.value <= UInt32(NSF35FunctionKey)
}) == true else { return nil }
var modifiers: KeyModifiers = []
if event.modifierFlags.contains(.command) { modifiers.insert(.command) }
if event.modifierFlags.contains(.control) { modifiers.insert(.control) }
if event.modifierFlags.contains(.option) { modifiers.insert(.option) }
if event.modifierFlags.contains(.shift) { modifiers.insert(.shift) }
if let key = nativeKeys[event.keyCode] { return KeyChord(key: key, modifiers: modifiers) }
guard let text = event.charactersIgnoringModifiers, text.count == 1,
let scalar = text.unicodeScalars.first else { return nil }
if let key = functionKeys[scalar.value] {
return KeyChord(key: key, modifiers: modifiers)
}
guard !CharacterSet.controlCharacters.contains(scalar),
!(0xF700...0xF8FF).contains(scalar.value) else { return nil }
return KeyChord(key: text.lowercased(), modifiers: modifiers)
}
var keyboardShortcut: KeyboardShortcut? {
let equivalent: KeyEquivalent
switch key {
case "space": equivalent = .space
case "escape": equivalent = .escape
case "return": equivalent = .return
case "tab": equivalent = .tab
case "backspace": equivalent = .delete
case "delete": equivalent = .deleteForward
case "left": equivalent = .leftArrow
case "right": equivalent = .rightArrow
case "up": equivalent = .upArrow
case "down": equivalent = .downArrow
case "home": equivalent = .home
case "end": equivalent = .end
case "pageup": equivalent = .pageUp
case "pagedown": equivalent = .pageDown
default:
if key.count == 1, let first = key.first { equivalent = KeyEquivalent(first) }
else if key.hasPrefix("f"), let number = Int(key.dropFirst()), (1...35).contains(number) {
equivalent = KeyEquivalent(Character(UnicodeScalar(UInt32(NSF1FunctionKey) + UInt32(number - 1))!))
} else { return nil }
}
var flags: EventModifiers = []
if modifiers.contains(.command) { flags.insert(.command) }
if modifiers.contains(.control) { flags.insert(.control) }
if modifiers.contains(.option) { flags.insert(.option) }
if modifiers.contains(.shift) { flags.insert(.shift) }
return KeyboardShortcut(equivalent, modifiers: flags)
}
}
extension PlayerModel {
private static let nativeNavigationKeys: Set<String> = [
"space", "return", "escape", "left", "right", "up", "down", "home", "end", "pageup", "pagedown"
]
// The local monitor calls this exact adapter; checks can supply real AppKit events.
@discardableResult
func handleKeyEvent(_ event: NSEvent, in window: NSWindow) -> Bool {
guard let chord = KeyChord.from(event) else { return false }
return handleKeyEvent(event, in: window, chord: chord)
}
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil,
!(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false }
// Preserve focus traversal, control activation/navigation and VoiceOver's own chords.
if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false }
if window.firstResponder is NSControl,
chord.modifiers.subtracting(.shift).isEmpty,
Self.nativeNavigationKeys.contains(chord.key) {
return false
}
if NSWorkspace.shared.isVoiceOverEnabled,
chord.modifiers.contains([.control, .option]) { return false }
return handleKey(chord, isRepeat: event.isARepeat)
}
func performMenuAction(_ action: PlayerAction) {
if let event = NSApp.currentEvent, event.type == .keyDown {
if event.isARepeat && !action.allowsRepeat { return }
guard let chord = KeyChord.from(event),
keyBindings.shortcuts(for: action).contains(chord) else {
// Return/Space activating a selected menu item is native navigation, not a player shortcut.
perform(action)
return
}
if let window = event.window ?? NSApp.keyWindow,
!handleKeyEvent(event, in: window, chord: chord),
let responder = window.firstResponder as? NSView {
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
}
return
}
perform(action)
}
}
@MainActor
func PlayerActionButton(model: PlayerModel, action: PlayerAction, title: String,
selected: Bool = false) -> some View {
// Context menus use the same explicit actions as the native application menus.
Button { model.performMenuAction(action) } label: {
Text(selected ? "✓ \(title)" : title)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: action).first?.keyboardShortcut)
.disabled(!model.canPerform(action))
}
@MainActor
struct PlayerQualityMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
Menu("Quality") {
PlayerActionButton(model: model, action: model.automaticQualityAction,
title: model.automaticYouTubeQuality ? "Disable Automatic Quality" : "Enable Automatic Quality (up to 1080p)")
Divider()
PlayerActionButton(model: model, action: .qualityDefault, title: "Default (up to 1080p)",
selected: !model.automaticYouTubeQuality && model.youtubeQuality == nil)
ForEach(model.youtubeHeights, id: \.self) { height in
PlayerActionButton(model: model, action: .quality(height), title: "\(height)p",
selected: !model.automaticYouTubeQuality && model.youtubeQuality == height)
}
if !model.youtubeHeights.isEmpty {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
}
}
}
@MainActor
struct PlayerCommands: Commands {
@FocusedObject private var focusedModel: PlayerModel?
var body: some Commands {
// The player has one scene; app-level Open/Quit still work before it becomes focused.
let model = focusedModel ?? PlayerModel.shared
let hasFocusedScene = focusedModel != nil
let playback = model.playbackAction
let mute = model.muteAction
let fullscreen = model.fullscreenAction
let compact = model.compactAction
let onTop = model.onTopAction
let bufferInfo = model.bufferInfoAction
let snapping = model.snappingAction
let automaticQuality = model.automaticQualityAction
CommandGroup(replacing: .appTermination) {
Button("Quit terebi") { model.performMenuAction(.quit) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .quit).first?.keyboardShortcut)
.disabled(!model.canPerform(.quit))
}
CommandGroup(replacing: .newItem) {
Button("Open File…") { model.performMenuAction(.openFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut)
.disabled(!model.canPerform(.openFile))
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
.disabled(!model.canPerform(.openYoutube))
Button("Close File") { model.performMenuAction(.closeFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
}
CommandGroup(replacing: .saveItem) {
Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
.disabled(focusedModel == nil)
}
CommandMenu("Playback") {
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(playback))
Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s))
Button("Seek Forward 5 Seconds") { model.performMenuAction(.seekForward5s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekForward5s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekForward5s))
Button("Seek Back 10 Seconds") { model.performMenuAction(.seekBackward10s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward10s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward10s))
Button("Seek Forward 10 Seconds") { model.performMenuAction(.seekForward10s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekForward10s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekForward10s))
Divider()
Button("Previous Frame") { model.performMenuAction(.stepFrameBackward) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .stepFrameBackward).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.stepFrameBackward))
Button("Next Frame") { model.performMenuAction(.stepFrameForward) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .stepFrameForward).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.stepFrameForward))
Divider()
Button("Volume Up") { model.performMenuAction(.volumeUp) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .volumeUp).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.volumeUp))
Button("Volume Down") { model.performMenuAction(.volumeDown) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .volumeDown).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.volumeDown))
Button(model.volume == 0 ? "Unmute" : "Mute") { model.performMenuAction(mute) }
.keyboardShortcut(model.keyBindings.shortcuts(for: mute).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(mute))
Divider()
Menu("Quality") {
Button(model.automaticYouTubeQuality ? "Disable Automatic Quality" : "Enable Automatic Quality (up to 1080p)") {
model.performMenuAction(automaticQuality)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: automaticQuality).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(automaticQuality))
Divider()
Button(!model.automaticYouTubeQuality && model.youtubeQuality == nil ?
"✓ Default (up to 1080p)" : "Default (up to 1080p)") {
model.performMenuAction(.qualityDefault)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: .qualityDefault).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.qualityDefault))
ForEach(model.youtubeHeights, id: \.self) { height in
Button(!model.automaticYouTubeQuality && model.youtubeQuality == height ? "✓ \(height)p" : "\(height)p") {
model.performMenuAction(.quality(height))
}
.keyboardShortcut(model.keyBindings.shortcuts(for: .quality(height)).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.quality(height)))
}
if !model.youtubeHeights.isEmpty {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
}
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: bufferInfo).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
}
CommandGroup(replacing: .sidebar) {
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
model.performMenuAction(fullscreen)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: fullscreen).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(fullscreen))
}
CommandGroup(after: .windowArrangement) {
Button(compact == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode") {
model.performMenuAction(compact)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: compact).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(compact))
Button(model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping") {
model.performMenuAction(snapping)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: snapping).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(snapping))
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top") {
model.performMenuAction(onTop)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: onTop).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(onTop))
}
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ final class SystemPlaybackControls {
switch action {
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
File diff suppressed because it is too large Load Diff
+68 -17
View File
@@ -2,34 +2,85 @@ import Foundation
enum PlayerCommand {
case help
case launch(URL?)
case printDefaultConfig
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
static let usage = """
Usage: terebi [FILE | YOUTUBE_URL]
terebi --url YOUTUBE_URL
terebi -- FILE
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
terebi --new-instance --url YOUTUBE_URL --progress-file PATH
terebi [--new-instance] [--no-config] -- FILE
terebi --help
terebi --default-config
Open one local media file or YouTube video and start playback, resuming saved
video progress when available. With no arguments, open or activate the player.
Quote paths and URLs containing spaces or shell metacharacters.
--url URL Open a YouTube video URL.
-- Treat the following argument as a filename, even with a leading '-'.
-h, --help Show this help without launching the player.
--new-instance Launch a separate player, leaving existing playback untouched.
--no-config Use built-in settings instead of config.toml on a fresh launch.
--url URL Open a YouTube video URL.
--progress-file PATH Write JSONL playback facts to a new private file; requires
--new-instance. The GUI owns reporting after this CLI exits.
-- Treat the following argument as a filename, even with a leading '-'.
-h, --help Show this help without launching the player.
--default-config Print the complete built-in TOML without launching.
Reuses the running player when available. The command exits once macOS accepts
the launch; playback and extraction errors appear in the player window.
Reuses the running player unless --new-instance is supplied before the media
arguments. The command exits once macOS accepts the launch; playback and
extraction errors appear in the player window.
"""
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
if arguments.isEmpty { return .launch(nil) }
if arguments == ["--help"] || arguments == ["-h"] { return .help }
if arguments.first == "--url" {
guard arguments.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return .launch(try YouTubeResolver.validateURL(arguments[1]))
}
var inputs = arguments
var progressFile: URL?
var index = 0
while index < inputs.count && inputs[index] != "--" {
if inputs[index] != "--progress-file" {
index += 1
continue
}
guard progressFile == nil else { throw ArgumentError("--progress-file may only be supplied once.") }
guard index + 1 < inputs.count, !inputs[index + 1].isEmpty,
!inputs[index + 1].contains("\0"), !inputs[index + 1].hasPrefix("--") else {
throw ArgumentError("--progress-file requires a filesystem path.")
}
let base = directory.hasDirectoryPath ? directory :
URL(fileURLWithPath: directory.path, isDirectory: true)
progressFile = URL(fileURLWithPath: inputs[index + 1], relativeTo: base).absoluteURL.standardizedFileURL
inputs.removeSubrange(index...index + 1)
}
var newInstance = false
var noConfig = false
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
if option == "--new-instance" {
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
newInstance = true
} else {
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
noConfig = true
}
inputs.removeFirst()
}
func launch(_ url: URL?) throws -> PlayerCommand {
guard progressFile == nil || newInstance else {
throw ArgumentError("--progress-file requires --new-instance.")
}
return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
}
if inputs.isEmpty { return try launch(nil) }
if inputs == ["--help"] || inputs == ["-h"] {
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") }
return .help
}
if inputs == ["--default-config"] {
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
return .printDefaultConfig
}
if inputs.first == "--url" {
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return try launch(YouTubeResolver.validateURL(inputs[1]))
}
let filenameOnly = inputs.first == "--"
if filenameOnly {
inputs.removeFirst()
@@ -48,7 +99,7 @@ enum PlayerCommand {
}
file = url
} else {
return .launch(try YouTubeResolver.validateURL(input))
return try launch(YouTubeResolver.validateURL(input))
}
} else {
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
@@ -58,7 +109,7 @@ enum PlayerCommand {
FileManager.default.isReadableFile(atPath: file.path) else {
throw ArgumentError("File does not exist or is not a readable regular file.")
}
return .launch(file.standardizedFileURL)
return try launch(file.standardizedFileURL)
}
private struct ArgumentError: LocalizedError {
+33 -3
View File
@@ -1,5 +1,6 @@
import AppKit
import Darwin
import TerebiConfiguration
@main
@MainActor
@@ -14,7 +15,27 @@ enum TerebiCLI {
switch command {
case .help:
print(PlayerCommand.usage)
case .launch(let url):
case .printDefaultConfig:
print(PlayerConfiguration.defaultTOML, terminator: "")
case .launch(let url, let newInstance, let noConfig, let progressFile):
if let progressFile {
var existing = stat()
guard lstat(progressFile.path, &existing) != 0 else {
fail("Progress file already exists: \(progressFile.path). Supply a nonexistent destination.", code: 73)
}
guard errno == ENOENT else {
fail("Could not inspect progress file \(progressFile.path): \(String(cString: strerror(errno))).", code: 73)
}
}
let configURL = PlayerConfiguration.fileURL(environment: ProcessInfo.processInfo.environment,
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
if !noConfig {
do {
_ = try PlayerConfiguration.load(from: configURL)
} catch {
fail(error.localizedDescription, code: 78)
}
}
guard let executable = Bundle.main.executableURL else {
fail("Could not locate the CLI executable.", code: 69)
}
@@ -24,10 +45,19 @@ enum TerebiCLI {
fail(error, code: 69)
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.createsNewApplicationInstance = newInstance
// LaunchServices does not inherit the invoking shell's XDG environment.
configuration.arguments = noConfig ? ["--no-config"] : ["--config-file", configURL.absoluteString]
if let progressFile {
configuration.arguments += ["--progress-file", progressFile.path]
}
if newInstance, let url {
configuration.arguments += ["--open-media", url.absoluteString]
}
do {
// Launch without media first so SwiftUI creates its singleton window.
// Launch an application, not a document, so SwiftUI creates its singleton window.
_ = try await NSWorkspace.shared.openApplication(at: app, configuration: configuration)
if let url {
if !newInstance, let url {
_ = try await NSWorkspace.shared.open([url], withApplicationAt: app, configuration: configuration)
}
} catch {
+135
View File
@@ -0,0 +1,135 @@
import Foundation
public struct KeyModifiers: OptionSet, Hashable, Sendable {
public let rawValue: UInt8
public init(rawValue: UInt8) { self.rawValue = rawValue }
public static let command = Self(rawValue: 1 << 0)
public static let control = Self(rawValue: 1 << 1)
public static let option = Self(rawValue: 1 << 2)
public static let shift = Self(rawValue: 1 << 3)
}
public struct KeyChord: Hashable, Sendable {
public let key: String
public let modifiers: KeyModifiers
public init(key: String, modifiers: KeyModifiers) {
self.key = key
self.modifiers = modifiers
}
public init(_ text: String) throws {
let parts = text.lowercased().split(separator: "+", omittingEmptySubsequences: false)
func invalid(_ reason: String) -> BindingError {
BindingError(message: "Invalid chord '\(text)': \(reason)")
}
guard let last = parts.last, !last.isEmpty, parts.allSatisfy({ !$0.isEmpty }) else {
throw invalid("empty key or modifier; use 'plus' for the + key.")
}
var modifiers: KeyModifiers = []
for part in parts.dropLast() {
let modifier: KeyModifiers
switch part {
case "cmd": modifier = .command
case "ctrl": modifier = .control
case "alt", "option": modifier = .option
case "shift": modifier = .shift
default: throw invalid("unknown modifier '\(part)'.")
}
guard !modifiers.contains(modifier) else { throw invalid("duplicate modifier '\(part)'.") }
modifiers.insert(modifier)
}
let key = String(last)
let namedKeys: Set<String> = ["space", "escape", "return", "tab", "backspace", "delete",
"left", "right", "up", "down", "home", "end", "pageup", "pagedown", "plus"]
let functionKey: Bool
if key.hasPrefix("f"), let number = Int(key.dropFirst()) {
functionKey = (1...35).contains(number) && key == "f\(number)"
} else {
functionKey = false
}
let printable = key.count == 1 && key != "+" && key.unicodeScalars.allSatisfy {
!CharacterSet.controlCharacters.contains($0) && !CharacterSet.whitespacesAndNewlines.contains($0)
}
guard namedKeys.contains(key) || functionKey || printable else { throw invalid("unknown key '\(key)'.") }
guard !(key == "tab" && modifiers.contains(.command)) else {
throw invalid("unsupported OS-reserved cmd+tab shortcut.")
}
self.init(key: key == "plus" ? "+" : key, modifiers: modifiers)
}
public var description: String {
var parts: [String] = []
if modifiers.contains(.control) { parts.append("ctrl") }
if modifiers.contains(.option) { parts.append("alt") }
if modifiers.contains(.shift) { parts.append("shift") }
if modifiers.contains(.command) { parts.append("cmd") }
parts.append(key == "+" ? "plus" : key)
return parts.joined(separator: "+")
}
}
public struct KeyBindings: Equatable, Sendable {
private let byAction: [PlayerAction: [KeyChord]]
private let byChord: [KeyChord: [PlayerAction]]
private static let builtIn: [PlayerAction: [KeyChord]] = {
let texts: [PlayerAction: [String]] = [
.play: ["k", "space"], .pause: ["k", "space"],
.mute: ["m"], .unmute: ["m"], .volumeUp: ["up"], .volumeDown: ["down"],
.seekForward5s: ["right"], .seekBackward5s: ["left"],
.seekForward10s: ["l"], .seekBackward10s: ["j"],
.stepFrameForward: ["."], .stepFrameBackward: [","],
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
.openFile: ["o", "cmd+o"], .openYoutube: ["cmd+shift+o"],
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
]
return texts.mapValues { $0.map { try! KeyChord($0) } }
}()
private static let defaults = try! KeyBindings(overrides: [:])
public init() { self = Self.defaults }
public init(overrides: [String: [String]]) throws {
var bindings = Self.builtIn
for name in overrides.keys.sorted() {
guard let action = PlayerAction(name: name) else {
throw BindingError(message: "Unknown action 'keys.\(name)'.")
}
var chords: [KeyChord] = []
var seen: Set<KeyChord> = []
for text in overrides[name]! {
let chord: KeyChord
do { chord = try KeyChord(text) }
catch { throw BindingError(message: "keys.\(name): \(error.localizedDescription)") }
guard seen.insert(chord).inserted else {
throw BindingError(message: "keys.\(name): duplicate chord '\(text)' (\(chord.description)).")
}
chords.append(chord)
}
if chords.isEmpty { bindings.removeValue(forKey: action) }
else { bindings[action] = chords }
}
var lookup: [KeyChord: [PlayerAction]] = [:]
for action in bindings.keys.sorted(by: { $0.name < $1.name }) {
for chord in bindings[action]! {
if let existing = lookup[chord]?.first, existing.inverse != action {
throw BindingError(message: "Chord '\(chord.description)' conflicts between keys.\(existing.name) and keys.\(action.name).")
}
lookup[chord, default: []].append(action)
}
}
byAction = bindings
byChord = lookup
}
public func shortcuts(for action: PlayerAction) -> [KeyChord] { byAction[action] ?? [] }
public func actions(for chord: KeyChord) -> [PlayerAction] { byChord[chord] ?? [] }
}
struct BindingError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
+78
View File
@@ -0,0 +1,78 @@
public enum PlayerAction: Hashable, Sendable {
case play, pause, mute, unmute, volumeUp, volumeDown
case seekForward5s, seekBackward5s, seekForward10s, seekBackward10s
case stepFrameForward, stepFrameBackward
case enterFullscreen, exitFullscreen, enterCompact, exitCompact
case enableAlwaysOnTop, disableAlwaysOnTop
case openFile, openYoutube, closeFile, quit
case showBufferInfo, hideBufferInfo
case enableCompactSnapping, disableCompactSnapping
case enableAutomaticQuality, disableAutomaticQuality
case qualityDefault, quality(Int)
static let namedActions: [(String, PlayerAction)] = [
("play", .play), ("pause", .pause), ("mute", .mute), ("unmute", .unmute),
("volume_up", .volumeUp), ("volume_down", .volumeDown),
("seek_forward_5s", .seekForward5s), ("seek_backward_5s", .seekBackward5s),
("seek_forward_10s", .seekForward10s), ("seek_backward_10s", .seekBackward10s),
("step_frame_forward", .stepFrameForward), ("step_frame_backward", .stepFrameBackward),
("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen),
("enter_compact", .enterCompact), ("exit_compact", .exitCompact),
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
("open_file", .openFile), ("open_youtube", .openYoutube), ("close_file", .closeFile), ("quit", .quit),
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
("quality_default", .qualityDefault)
]
private static let actionsByName = Dictionary(uniqueKeysWithValues: namedActions)
private static let namesByAction = Dictionary(uniqueKeysWithValues: namedActions.map { ($0.1, $0.0) })
public init?(name: String) {
if let action = Self.actionsByName[name] {
self = action
} else if name.hasPrefix("quality_"), name.hasSuffix("p") {
let digits = name.dropFirst("quality_".count).dropLast()
guard !digits.isEmpty, digits.allSatisfy({ $0 >= "0" && $0 <= "9" }),
let height = Int(digits), height > 0, String(height) == digits else { return nil }
self = .quality(height)
} else {
return nil
}
}
public var name: String {
if case .quality(let height) = self { return "quality_\(height)p" }
return Self.namesByAction[self]!
}
public var inverse: PlayerAction? {
switch self {
case .play: return .pause
case .pause: return .play
case .mute: return .unmute
case .unmute: return .mute
case .enterFullscreen: return .exitFullscreen
case .exitFullscreen: return .enterFullscreen
case .enterCompact: return .exitCompact
case .exitCompact: return .enterCompact
case .enableAlwaysOnTop: return .disableAlwaysOnTop
case .disableAlwaysOnTop: return .enableAlwaysOnTop
case .showBufferInfo: return .hideBufferInfo
case .hideBufferInfo: return .showBufferInfo
case .enableCompactSnapping: return .disableCompactSnapping
case .disableCompactSnapping: return .enableCompactSnapping
case .enableAutomaticQuality: return .disableAutomaticQuality
case .disableAutomaticQuality: return .enableAutomaticQuality
default: return nil
}
}
public var allowsRepeat: Bool {
switch self {
case .volumeUp, .volumeDown, .seekForward5s, .seekBackward5s,
.seekForward10s, .seekBackward10s, .stepFrameForward, .stepFrameBackward: return true
default: return false
}
}
}
@@ -0,0 +1,210 @@
import Foundation
import TOMLDecoder
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,
keys: KeyBindings = KeyBindings()) {
self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges
self.keys = keys
}
public static var defaultTOML: String {
let defaults = Self()
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
var lines = [
"[playback]",
"automatic_quality = \(defaults.automaticQuality)",
"", "[ui]",
"buffer_info = \(defaults.showBufferInfo)",
"compact_snap_to_edges = \(defaults.snapToEdges)",
"", "[keys]"
]
for (name, action) in PlayerAction.namedActions {
let chords = defaults.keys.shortcuts(for: action).map(\.description)
// JSON string arrays are valid TOML arrays; preserve escaping for printable keys.
let value = String(decoding: try! encoder.encode(chords), as: UTF8.self)
lines.append("\(name) = \(value)")
}
return lines.joined(separator: "\n") + "\n"
}
public static func fileURL(environment: [String: String], homeDirectory: URL) -> URL {
let directory: URL
if let path = environment["XDG_CONFIG_HOME"], path.hasPrefix("/") {
directory = URL(fileURLWithPath: path, isDirectory: true)
} else {
directory = homeDirectory.appendingPathComponent(".config", isDirectory: true)
}
return directory.appendingPathComponent("terebi/config.toml")
}
public static func loadForApplication(arguments: [String], environment: [String: String],
homeDirectory: URL) throws -> Self {
if arguments.contains("--no-config") { return Self() }
if let index = arguments.firstIndex(of: "--config-file") {
guard index + 1 < arguments.count, let url = URL(string: arguments[index + 1]), url.isFileURL,
url.host == nil || url.host == "" || url.host == "localhost" else {
throw ConfigurationError(message: "--config-file requires a local file URL.")
}
return try load(from: url)
}
return try load(from: fileURL(environment: environment, homeDirectory: homeDirectory))
}
public static func load(from url: URL) throws -> Self {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
let failure = error as NSError
let underlying = failure.userInfo[NSUnderlyingErrorKey] as? NSError
if failure.domain == NSCocoaErrorDomain, failure.code == CocoaError.fileReadNoSuchFile.rawValue,
underlying?.domain == NSPOSIXErrorDomain, underlying?.code == Int(ENOENT) {
return Self()
}
throw ConfigurationError(message: "\(url.path): \(error.localizedDescription)")
}
do {
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
automaticQuality: values.playback?.automaticQuality ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true,
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
}
}
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): 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)
}
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 playback: PlaybackConfiguration?
let ui: UIConfiguration?
let keys: KeysConfiguration?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: SettingKey.self)
try validate(container, allowed: ["playback", "ui", "keys"])
if container.contains(SettingKey("playback")) {
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
} else { playback = nil }
if container.contains(SettingKey("ui")) {
ui = try UIConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("ui")))
} else { ui = nil }
if container.contains(SettingKey("keys")) {
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
} else { keys = nil }
}
}
private struct PlaybackConfiguration {
let automaticQuality: Bool?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["automatic_quality"])
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
}
}
private struct UIConfiguration {
let bufferInfo: Bool?
let compactSnapToEdges: Bool?
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 {
let message: String
var errorDescription: String? { message }
}
}
+299 -93
View File
@@ -11,27 +11,61 @@ extern "C" {
#include <cerrno>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <iterator>
#include <limits>
#include <mutex>
#include <new>
#include <stdexcept>
#include <thread>
namespace playback {
class NetworkInput;
// One budget per player, shared by combined or separate compressed inputs.
class StreamCacheBudget {
public:
static constexpr std::size_t byteLimit = 128 * 1024 * 1024;
explicit StreamCacheBudget(std::size_t limit = byteLimit)
: limit(std::min(limit, byteLimit)) {
if (!this->limit) throw std::invalid_argument("The streaming cache budget must be positive");
}
std::size_t bytes() const {
std::lock_guard lock(mutex);
return used;
}
private:
friend class NetworkInput;
const std::size_t limit;
std::size_t used = 0;
mutable std::mutex mutex;
std::condition_variable changed;
std::array<NetworkInput *, 2> inputs{};
};
// Only network inputs need a reader: a blocked socket must never own the player worker.
// Format metadata is immutable after start(); the reader exclusively owns demux and seek I/O.
class NetworkInput {
public:
static constexpr std::size_t packetLimit = 64;
static constexpr std::size_t byteLimit = 16 * 1024 * 1024;
static constexpr double aheadSeconds = 60, behindSeconds = 30;
AVFormatContext *format = nullptr;
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
uint64_t activeEpoch, std::condition_variable &playerWake)
: stopping(stopping), epoch(epoch), activeEpoch(activeEpoch), playerWake(playerWake) {}
uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget)
: stopping(stopping), epoch(epoch), activeEpoch(activeEpoch), playerWake(playerWake), budget(budget) {
std::lock_guard lock(budget.mutex);
auto free = std::find(budget.inputs.begin(), budget.inputs.end(), nullptr);
if (free == budget.inputs.end()) throw std::invalid_argument("Too many streaming inputs");
*free = this;
}
~NetworkInput() {
stop();
for (auto &lane : lanes)
for (auto *packet : lane.packets) av_packet_free(&packet);
{
std::lock_guard lock(budget.mutex);
for (auto &lane : lanes) clear(lane);
for (auto &input : budget.inputs) if (input == this) input = nullptr;
budget.changed.notify_all();
}
av_packet_free(&scratch);
avformat_close_input(&format);
}
@@ -50,14 +84,25 @@ public:
operationSerial.store(serial.load(std::memory_order_acquire), std::memory_order_relaxed);
deadline.store(now() + 15'000'000'000LL, std::memory_order_relaxed);
}
void start(std::array<int, 2> indices, double timelineOrigin) {
origin = timelineOrigin;
for (int slot = 0; slot < 2; ++slot) {
lanes[slot].index = indices[slot];
if (indices[slot] < 0) continue;
for (auto &packet : lanes[slot].packets) {
packet = av_packet_alloc();
if (!packet) throw std::bad_alloc();
void start(std::array<int, 2> indices, double timelineOrigin, bool allowCachedSeeking) {
{
std::lock_guard lock(budget.mutex);
origin = timelineOrigin;
cacheSeeking = allowCachedSeeking;
started = true;
for (int slot = 0; slot < 2; ++slot) {
auto &lane = lanes[slot];
lane.index = indices[slot];
if (lane.index < 0) continue;
const auto *stream = format->streams[lane.index];
lane.tick = av_q2d(stream->time_base);
lane.mediaStart = stream->start_time == AV_NOPTS_VALUE ? 0 :
stream->start_time * lane.tick - origin;
if (stream->duration != AV_NOPTS_VALUE && stream->duration > 0)
lane.mediaEnd = lane.mediaStart + stream->duration * lane.tick;
if (slot == 0 && stream->codecpar->sample_rate > 0)
lane.preroll = std::max(stream->codecpar->seek_preroll, 0) /
static_cast<double>(stream->codecpar->sample_rate);
}
}
scratch = av_packet_alloc();
@@ -66,109 +111,172 @@ public:
}
void stop() {
cancelled.store(true, std::memory_order_relaxed);
changed.notify_all();
budget.changed.notify_all();
if (reader.joinable()) reader.join();
}
void updatePosition(double seconds) {
if (!std::isfinite(seconds)) return;
std::lock_guard lock(budget.mutex);
position = std::max(0.0, seconds);
for (int slot = 0; slot < 2; ++slot) trim(slot);
budget.changed.notify_all();
}
void seek(int streamIndex, int64_t timestamp) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
const double target = (streamIndex < 0 ? static_cast<double>(timestamp) / AV_TIME_BASE :
static_cast<double>(timestamp) * av_q2d(format->streams[streamIndex]->time_base)) - origin;
std::array<uint64_t, 2> cursors{};
bool cached = cacheSeeking && !seeking && !failure && std::isfinite(target);
for (int slot = 0; slot < 2 && cached; ++slot) {
const auto &lane = lanes[slot];
if (lane.index < 0 || (slot == 0 && skipAudio)) continue;
// A forced finish may have interrupted demux. Only a completely cached short
// track can resume without restoring its I/O position through a real seek.
if (halted && !ended && !(std::isfinite(lane.mediaEnd) &&
lane.frontierEnd + lane.tick >= lane.mediaEnd)) cached = false;
else cached = cachedCursor(slot, target, cursors[slot]);
}
position = std::max(0.0, target);
if (cached) {
for (int slot = 0; slot < 2; ++slot)
if (lanes[slot].index >= 0 && !(slot == 0 && skipAudio)) lanes[slot].cursor = cursors[slot];
// Cache hits never interrupt the outstanding read at the fetch frontier.
halted = false;
budget.changed.notify_all();
playerWake.notify_one();
return;
}
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = false;
audioDropped = false;
ended = halted = false;
failure = 0;
seekIndex = streamIndex;
seekTimestamp = timestamp;
seeking = true;
serial.fetch_add(1, std::memory_order_release);
changed.notify_all();
budget.changed.notify_all();
}
void discardAudio(bool discard) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
skipAudio = discard;
if (discard) clear(lanes[0]);
if (discard && scratchSlot == 0) scratchSlot = -1;
changed.notify_all();
// Stepping must not leave an unread audio lane blocking a combined demuxer.
if (discard) {
lanes[0].cursor = tail(lanes[0]);
trim(0);
}
budget.changed.notify_all();
}
void finish() {
std::lock_guard lock(mutex);
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = true;
std::lock_guard lock(budget.mutex);
for (auto &lane : lanes) lane.cursor = tail(lane);
halted = true;
seeking = false;
failure = 0;
serial.fetch_add(1, std::memory_order_release);
changed.notify_all();
budget.changed.notify_all();
}
bool seekPending() const {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
return seeking;
}
int error() const {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
return failure;
}
double bufferedEnd(int slot) const {
std::lock_guard lock(mutex);
if (failure || seeking) return 0;
double bufferedStart(int slot) const {
std::lock_guard lock(budget.mutex);
if (!cacheSeeking || failure || seeking || (slot == 0 && skipAudio)) return NAN;
const auto &lane = lanes[slot];
const double queued = lane.count ?
lane.ends[(lane.first + lane.count - 1) % packetLimit] : 0;
return std::max(queued, scratchSlot == slot ? scratchEnd : 0);
if (lane.points.empty() || (slot == 0 && audioDropped && !trackEnded(lane))) return NAN;
if (slot == 1 && (lane.packets.empty() || position > lane.packets.back().end + lane.tick)) return NAN;
const double start = lane.points.front().time + lane.preroll;
return std::max(0.0, lane.atBeginning ? std::min(start, lane.mediaStart) : start);
}
// 1: packet, 0: not yet available, AVERROR_EOF: this input is drained.
double bufferedEnd(int slot) const {
std::lock_guard lock(budget.mutex);
if (failure || seeking || (slot == 0 && skipAudio)) return 0;
const auto &lane = lanes[slot];
return lane.packets.empty() ? 0 : lane.packets.back().end;
}
// 1: packet, 0: not yet available, AVERROR_EOF: this lane is drained.
int take(int slot, AVPacket *destination, double &bufferedEnd) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
if (failure) return failure;
bufferedEnd = 0;
auto &lane = lanes[slot];
if (lane.count) {
auto *packet = lane.packets[lane.first];
bufferedEnd = lane.ends[lane.first];
lane.bytes -= packetBytes(packet);
av_packet_move_ref(destination, packet);
lane.first = (lane.first + 1) % packetLimit;
--lane.count;
changed.notify_one();
if (lane.cursor < tail(lane)) {
auto &packet = lane.packets[lane.cursor - lane.first];
const int result = av_packet_ref(destination, &packet.packet);
if (result < 0) return result;
bufferedEnd = packet.end;
++lane.cursor;
trim(slot);
budget.changed.notify_all();
return 1;
}
return ended ? AVERROR_EOF : 0;
return ended || halted || trackEnded(lane) ? AVERROR_EOF : 0;
}
private:
struct Packet {
AVPacket packet{};
double end = 0;
std::size_t bytes = 0;
~Packet() { av_packet_unref(&packet); }
Packet() = default;
Packet(const Packet &) = delete;
Packet &operator=(const Packet &) = delete;
};
struct SeekPoint { double time; uint64_t sequence; };
struct Lane {
std::array<AVPacket *, packetLimit> packets{};
std::array<double, packetLimit> ends{};
std::deque<Packet> packets;
std::deque<SeekPoint> points;
int index = -1;
std::size_t first = 0, count = 0, bytes = 0;
uint64_t first = 0, cursor = 0;
std::size_t bytes = 0;
double tick = 0, frontierEnd = 0, mediaStart = 0, mediaEnd = NAN, preroll = 0;
bool atBeginning = false;
};
const std::atomic<bool> &stopping;
const std::atomic<uint64_t> &epoch;
const uint64_t activeEpoch;
std::condition_variable &playerWake;
mutable std::mutex mutex;
std::condition_variable changed;
StreamCacheBudget &budget;
std::array<Lane, 2> lanes;
AVPacket *scratch = nullptr;
std::atomic<bool> cancelled{false};
std::atomic<uint64_t> serial{0}, operationSerial{0};
std::atomic<int64_t> deadline{std::numeric_limits<int64_t>::max()};
bool seeking = false, ended = false, skipAudio = false;
bool seeking = false, ended = false, halted = false, skipAudio = false;
bool cacheSeeking = false, started = false, audioDropped = false;
int seekIndex = -1, failure = 0;
int64_t seekTimestamp = 0;
int scratchSlot = -1;
double scratchEnd = 0, origin = 0;
double origin = 0, position = 0;
std::thread reader;
static int64_t now() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
static std::size_t packetBytes(const AVPacket *packet) {
if (packet->size < 0) return byteLimit + 1;
std::size_t bytes = static_cast<std::size_t>(packet->size);
if (bytes > byteLimit) return byteLimit + 1;
static uint64_t tail(const Lane &lane) { return lane.first + lane.packets.size(); }
static bool trackEnded(const Lane &lane) {
return std::isfinite(lane.mediaEnd) && lane.frontierEnd + lane.tick * 0.5 >= lane.mediaEnd;
}
static double packetTime(const Lane &lane, const AVPacket *packet) {
const int64_t timestamp = packet->pts != AV_NOPTS_VALUE ? packet->pts : packet->dts;
return timestamp == AV_NOPTS_VALUE ? NAN : static_cast<double>(timestamp) * lane.tick;
}
std::size_t packetBytes(const AVPacket *packet) const {
if (packet->size < 0) return budget.limit + 1;
std::size_t bytes = sizeof(Packet) + sizeof(SeekPoint);
const auto payload = packet->buf ? packet->buf->size : static_cast<std::size_t>(packet->size);
if (payload > budget.limit || bytes > budget.limit - payload) return budget.limit + 1;
bytes += payload;
for (int i = 0; i < packet->side_data_elems; ++i) {
if (packet->side_data[i].size > byteLimit - bytes) return byteLimit + 1;
bytes += packet->side_data[i].size;
const auto size = packet->side_data[i].size;
if (size > budget.limit || sizeof(AVPacketSideData) > budget.limit - size ||
bytes > budget.limit - size - sizeof(AVPacketSideData)) return budget.limit + 1;
bytes += size + sizeof(AVPacketSideData);
}
return bytes;
}
@@ -176,23 +284,102 @@ private:
if (packet->pts == AV_NOPTS_VALUE || (packet->flags & AV_PKT_FLAG_DISCARD)) return 0;
int64_t timestamp = packet->pts;
if (slot == 1) {
// PTS alone can jump to a future reference frame before its B-frames arrive.
// Unknown DTS contributes no encoded-video estimate; decoded frames still count.
// PTS can jump to a future reference frame before its B-frames arrive.
if (packet->dts == AV_NOPTS_VALUE) return 0;
timestamp = std::min(timestamp, packet->dts);
}
const double tick = av_q2d(format->streams[lanes[slot].index]->time_base);
const double tick = lanes[slot].tick;
const double end = static_cast<double>(timestamp) * tick - origin +
static_cast<double>(std::max<int64_t>(packet->duration, 0)) * tick;
return std::isfinite(end) && end > 0 ? end : 0;
}
static void clear(Lane &lane) {
while (lane.count) {
av_packet_unref(lane.packets[lane.first]);
lane.first = (lane.first + 1) % packetLimit;
--lane.count;
void pop(Lane &lane) {
const auto bytes = lane.packets.front().bytes;
budget.used -= bytes;
lane.bytes -= bytes;
lane.packets.pop_front();
++lane.first;
lane.atBeginning = false;
while (!lane.points.empty() && lane.points.front().sequence < lane.first) lane.points.pop_front();
}
void clear(Lane &lane) {
budget.used -= lane.bytes;
lane.packets.clear();
lane.points.clear();
lane.first = lane.cursor = 0;
lane.bytes = 0;
lane.frontierEnd = 0;
lane.atBeginning = false;
}
static auto pointAt(const Lane &lane, double seconds) {
return std::upper_bound(lane.points.begin(), lane.points.end(), seconds,
[](double value, const SeekPoint &point) { return value < point.time; });
}
bool cachedCursor(int slot, double target, uint64_t &cursor) const {
const auto &lane = lanes[slot];
if (slot == 0 && std::isfinite(lane.mediaEnd) && target + lane.tick >= lane.mediaEnd) {
cursor = tail(lane);
return true;
}
lane.first = lane.bytes = 0;
if (slot == 0 && audioDropped && !trackEnded(lane)) return false;
if (lane.points.empty() || lane.packets.empty() ||
target > lane.packets.back().end + lane.tick) return false;
const auto after = pointAt(lane, target - lane.preroll);
if (after == lane.points.begin()) {
if (!lane.atBeginning) return false;
cursor = lane.points.front().sequence;
} else cursor = std::prev(after)->sequence;
return true;
}
void trim(int slot) {
auto &lane = lanes[slot];
uint64_t keep = lane.cursor;
if (cacheSeeking) {
const auto after = pointAt(lane, position - behindSeconds - lane.preroll);
if (after == lane.points.begin()) return;
keep = std::min(keep, std::prev(after)->sequence);
}
while (!lane.packets.empty() && lane.first < keep) pop(lane);
}
std::size_t laneLimit() const {
unsigned tracks = 0;
for (const auto *input : budget.inputs) {
if (!input) continue;
if (!input->started) { ++tracks; continue; }
for (int slot = 0; slot < 2; ++slot)
if (input->lanes[slot].index >= 0 && !(slot == 0 && input->skipAudio)) ++tracks;
}
// Each required track gets a reserved eighth; spare capacity remains borrowable.
return tracks > 1 ? budget.limit - budget.limit / 8 : budget.limit;
}
bool makeRoom(Lane &lane, std::size_t bytes) {
const auto quota = std::max(laneLimit(), bytes);
while (budget.used > budget.limit - bytes || lane.bytes > quota - bytes) {
Lane *oldest = nullptr;
double age = -std::numeric_limits<double>::infinity();
for (auto *input : budget.inputs) {
if (!input) continue;
for (auto &candidate : input->lanes) {
if (candidate.packets.empty() || candidate.first >= candidate.cursor) continue;
if (lane.bytes > quota - bytes && &candidate != &lane) continue;
const double distance = input->position - candidate.packets.front().end;
if (!oldest || distance > age) { oldest = &candidate; age = distance; }
}
}
if (!oldest) return false;
pop(*oldest);
}
return true;
}
bool needsRead() const {
for (int slot = 0; slot < 2; ++slot) {
const auto &lane = lanes[slot];
if (lane.index < 0 || (slot == 0 && skipAudio)) continue;
const double goal = std::isfinite(lane.mediaEnd) ?
std::min(position + aheadSeconds, lane.mediaEnd) : position + aheadSeconds;
if (lane.frontierEnd + lane.tick < goal) return true;
}
return false;
}
bool stoppingNow() const {
return cancelled.load(std::memory_order_relaxed) ||
@@ -206,12 +393,12 @@ private:
int64_t timestamp;
bool doSeek;
{
std::unique_lock lock(mutex);
changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
return stoppingNow() || seeking || !ended;
std::unique_lock lock(budget.mutex);
budget.changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
return stoppingNow() || seeking || (!ended && !halted && needsRead());
});
if (stoppingNow()) return;
if (ended && !seeking) continue;
if (!seeking && (ended || halted || !needsRead())) continue;
current = serial.load(std::memory_order_acquire);
doSeek = seeking;
index = seekIndex;
@@ -222,13 +409,14 @@ private:
if (doSeek) {
const int result = index >= 0 ?
av_seek_frame(format, index, timestamp, AVSEEK_FLAG_BACKWARD) :
avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(), timestamp, timestamp, 0);
avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(),
timestamp, timestamp, AVSEEK_FLAG_BACKWARD);
if (result >= 0 && format->pb) {
// An interrupted read leaves AVIO's error sticky even after a successful format seek.
// Interrupted reads leave AVIO's error sticky after a successful seek.
format->pb->error = 0;
format->pb->eof_reached = 0;
}
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
if (serial.load(std::memory_order_acquire) != current) continue;
seeking = false;
if (result < 0) { failure = result; ended = true; }
@@ -236,14 +424,14 @@ private:
if (result < 0) continue;
}
const int result = av_read_frame(format, scratch);
std::unique_lock lock(mutex);
std::unique_lock lock(budget.mutex);
if (stoppingNow()) return;
if (serial.load(std::memory_order_acquire) != current) {
av_packet_unref(scratch);
continue;
}
if (result == AVERROR(EAGAIN)) {
changed.wait_for(lock, std::chrono::milliseconds(5));
budget.changed.wait_for(lock, std::chrono::milliseconds(5));
continue;
}
if (result < 0) {
@@ -256,38 +444,56 @@ private:
int slot = -1;
for (int i = 0; i < 2; ++i)
if (lanes[i].index >= 0 && scratch->stream_index == lanes[i].index) slot = i;
if (slot < 0 || (slot == 0 && skipAudio)) { av_packet_unref(scratch); continue; }
if (slot < 0 || (slot == 0 && skipAudio)) {
if (slot == 0) audioDropped = true;
av_packet_unref(scratch);
continue;
}
auto &lane = lanes[slot];
const auto bytes = packetBytes(scratch);
if (bytes > byteLimit) {
failure = AVERROR(ENOBUFS);
if (bytes > budget.limit) {
failure = scratch->size < 0 ? AVERROR_INVALIDDATA : AVERROR(ENOBUFS);
ended = true;
av_packet_unref(scratch);
playerWake.notify_one();
continue;
}
scratchSlot = slot;
scratchEnd = packetEnd(slot, scratch);
changed.wait(lock, [&] {
// The demuxer's single in-flight packet is not retained until it fits.
// Byte pressure shortens consumed history, never drops unread packets.
budget.changed.wait(lock, [&] {
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
(slot == 0 && skipAudio) ||
(lane.count < packetLimit && lane.bytes + bytes <= byteLimit);
(slot == 0 && skipAudio) || makeRoom(lane, bytes);
});
if (stoppingNow()) return;
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
if (slot == 0 && skipAudio) audioDropped = true;
av_packet_unref(scratch);
scratchSlot = -1;
continue;
}
const double time = packetTime(lane, scratch) - origin;
const double end = std::max(lane.frontierEnd, packetEnd(slot, scratch));
const uint64_t sequence = tail(lane);
lane.packets.emplace_back();
auto &packet = lane.packets.back();
packet.bytes = bytes;
packet.end = end;
av_packet_move_ref(&packet.packet, scratch);
lane.bytes += bytes;
lane.ends[(lane.first + lane.count) % packetLimit] = scratchEnd;
av_packet_move_ref(lane.packets[(lane.first + lane.count) % packetLimit], scratch);
++lane.count;
scratchSlot = -1;
budget.used += bytes;
lane.frontierEnd = end;
const bool seekPoint = !(packet.packet.flags & AV_PKT_FLAG_DISCARD) &&
(slot == 0 || (packet.packet.flags & AV_PKT_FLAG_KEY));
if (seekPoint && std::isfinite(time) &&
(lane.points.empty() || time >= lane.points.back().time)) {
lane.points.push_back({time, sequence});
if (lane.points.size() == 1 && sequence == lane.first &&
time <= lane.mediaStart + lane.tick) lane.atBeginning = true;
}
trim(slot);
playerWake.notify_one();
}
} catch (...) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
failure = AVERROR(ENOMEM);
ended = true;
playerWake.notify_one();
+117 -13
View File
@@ -1,6 +1,7 @@
#include "player.h"
#include "player_macos.h"
#include "output.hpp"
#include "progress_reporter.hpp"
extern "C" {
#include <libavcodec/avcodec.h>
@@ -85,6 +86,8 @@ struct Command {
double sourceDuration = 0;
bool network = false;
bool live = false;
std::string mediaURL{};
double initialPosition = 0;
};
void copyError(char *destination, const char *message) noexcept {
@@ -182,6 +185,7 @@ struct Player {
// Decoders, timeline and output are worker-owned; network readers exclusively own demux I/O.
AVFormatContext *format = nullptr;
playback::StreamCacheBudget cacheBudget;
std::array<std::unique_ptr<playback::NetworkInput>, 2> inputs;
bool network = false;
bool canSeek = false;
@@ -222,14 +226,19 @@ struct Player {
PlayerStatus status = PLAYER_EMPTY;
bool previewShown = false;
std::array<char, 512> error{};
std::unique_ptr<playback::ProgressReporter> reporter;
std::thread worker;
Player() : worker([this] { run(); }) {}
~Player() {
stopping.store(true, std::memory_order_relaxed);
requestedEpoch.fetch_add(1, std::memory_order_relaxed);
wake.notify_one();
~Player() { shutdown(); }
void shutdown() {
if (!stopping.exchange(true, std::memory_order_relaxed)) {
requestedEpoch.fetch_add(1, std::memory_order_relaxed);
wake.notify_one();
}
if (worker.joinable()) worker.join();
if (reporter) reporter->finish();
}
bool enqueue(Command command) {
@@ -337,8 +346,17 @@ struct Player {
(videoFinished ? audioEnd : std::min(audioEnd, videoEnd));
}
end = std::min(end, duration);
if (!std::isfinite(end) || end <= position) return;
state.buffered_start = position;
if (!std::isfinite(end) || end < position) return;
double start = 0;
for (int slot = 0; slot < 2; ++slot) {
const auto &track = tracks[slot];
if (!track.codec) continue;
double cached = track.input ? track.input->bufferedStart(slot) : NAN;
if (!std::isfinite(cached) && slot == 0 && audioDrained()) cached = this->audioEnd();
start = std::max(start, std::isfinite(cached) ? std::clamp(cached, 0.0, position) : position);
}
if (end <= start) return;
state.buffered_start = start;
state.buffered_end = end;
}
@@ -359,6 +377,7 @@ struct Player {
return (command.action == Action::Seek || command.action == Action::Step) &&
command.epoch == activeEpoch;
});
for (auto &input : inputs) if (input) input->updatePosition(position);
publishBufferedRange();
state.video_aspect_ratio = 0;
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
@@ -369,6 +388,33 @@ struct Player {
}
std::memcpy(state.error, error.data(), error.size());
++state.revision;
if (reporter && reportingInputsHealthy()) reporter->observe(state);
}
bool reportingInputsHealthy() const {
for (const auto &input : inputs) if (input && input->error()) return false;
return true;
}
void reportBoundary() {
if (!reporter || !reportingInputsHealthy()) return;
// This is the old worker-owned timeline, even if a new open has already
// replaced the public state with OPENING. Never sample a queued seek target.
PlayerState snapshot{};
snapshot.status = status;
snapshot.duration = duration;
snapshot.has_video = tracks[1].codec != nullptr;
snapshot.can_seek = canSeek;
{
std::lock_guard<std::mutex> lock(mutex);
snapshot.seek_pending = networkSeeksPending() ||
std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
return (command.action == Action::Seek || command.action == Action::Step) &&
command.epoch == activeEpoch;
});
}
snapshot.position = status == PLAYER_PLAYING && !snapshot.seek_pending ? clockPosition() : position;
reporter->observe(snapshot);
}
void release() {
@@ -409,6 +455,7 @@ struct Player {
// Preserve messages that point into one of this object's own state buffers.
std::array<char, 512> saved{};
copyError(saved.data(), message);
if (reporter) reporter->closeMedia();
release();
error = saved;
status = PLAYER_ERROR;
@@ -474,8 +521,8 @@ struct Player {
steadyAnchor = Clock::now();
}
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers) {
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake);
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, bool live) {
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
auto &input = *inputs[slot];
input.format = avformat_alloc_context();
if (!input.format) throw std::bad_alloc();
@@ -483,6 +530,10 @@ struct Player {
AVDictionary *options = nullptr;
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
if (result >= 0) result = av_dict_set(&options, "rw_timeout", "15000000", 0);
// Some YouTube CDNs throttle open-ended ranges; FFmpeg continues bounded requests.
if (result >= 0 && !live) result = av_dict_set(&options, "request_size", "10485760", 0);
if (result >= 0 && !live) result = av_dict_set(&options, "multiple_requests", "1", 0);
// Leave short-seek read-ahead disabled: cache misses must escape stalled responses.
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
if (result < 0) {
av_dict_free(&options);
@@ -504,12 +555,14 @@ struct Player {
}
void open(const Command &command) {
reportBoundary();
if (reporter) reporter->begin(command.mediaURL, command.live);
release();
network = command.network;
status = PLAYER_OPENING;
publish();
if (network) {
auto *primary = openNetworkInput(0, command.path, command.videoHeaders);
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live);
format = primary->format;
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
if (command.audioURL.empty()) {
@@ -517,7 +570,7 @@ struct Player {
} else {
if (!tracks[1].codec)
throw std::runtime_error("The selected video stream contains no playable video");
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders);
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live);
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
if (!tracks[0].codec)
throw std::runtime_error("The selected audio stream contains no playable audio");
@@ -608,10 +661,12 @@ struct Player {
std::array<int, 2> indices{-1, -1};
for (int slot = 0; slot < 2; ++slot)
if (tracks[slot].input == input.get()) indices[slot] = tracks[slot].index;
input->start(indices, origin);
input->start(indices, origin, canSeek);
}
}
status = PLAYER_PAUSED;
if (command.initialPosition > 0 && command.initialPosition < duration && canSeek)
seek(command.initialPosition, false);
publish();
}
@@ -853,13 +908,23 @@ struct Player {
void execute(const Command &command) {
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
activeEpoch = command.epoch;
if (reporter && (command.action == Action::Seek || command.action == Action::Step)) {
// Local seeks can finish without a pending publication. Preserve
// the transition so their acknowledgement still emits a record.
std::lock_guard<std::mutex> lock(mutex);
reporter->observe(state);
}
switch (command.action) {
case Action::Open: open(command); break;
case Action::Play: start(); break;
case Action::Pause: pause(); break;
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
case Action::Step: step(command.direction); break;
case Action::Close: release(); break;
case Action::Close:
reportBoundary();
if (reporter) reporter->closeMedia();
release();
break;
}
publish();
}
@@ -1279,7 +1344,11 @@ struct Player {
try { fail("Unexpected playback failure"); } catch (...) {}
}
}
try { release(); } catch (...) {}
try {
reportBoundary();
if (reporter) reporter->closeMedia();
release();
} catch (...) {}
}
};
@@ -1302,12 +1371,45 @@ extern "C" {
Player *player_create(void) {
try { return new Player; } catch (...) { return nullptr; }
}
bool player_set_progress_file(Player *player, const char *path, char *error) {
if (error) error[0] = '\0';
try {
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
std::lock_guard<std::mutex> lock(player->mutex);
if (player->reporter || player->stopping.load(std::memory_order_relaxed) ||
player->state.status != PLAYER_EMPTY || !player->commands.empty())
throw std::runtime_error("Progress reporting must be initialized once, before opening media");
player->reporter = std::make_unique<playback::ProgressReporter>(path);
return true;
} catch (const std::exception &failure) {
if (error) copyError(error, failure.what());
} catch (...) {
if (error) copyError(error, "Could not initialize progress reporting");
}
return false;
}
bool player_get_progress_error(Player *player, char *error) {
if (error) error[0] = '\0';
if (!player || !player->reporter) return false;
const auto failure = player->reporter->error();
if (failure.empty()) return false;
if (error) copyError(error, failure.c_str());
return true;
}
bool player_shutdown(Player *player, char *progress_error) {
if (progress_error) progress_error[0] = '\0';
if (!player) return false;
player->shutdown();
return !player_get_progress_error(player, progress_error);
}
bool player_open(Player *player, const char *path) {
if (!path || !*path) return false;
return submit(player, [=] { return player->enqueue({Action::Open, path}); });
}
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
!std::isfinite(source->initial_position) || source->initial_position < 0 ||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaURL(source->media_url)) ||
!validNetworkURL(source->video_url) ||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
@@ -1319,6 +1421,8 @@ bool player_open_network(Player *player, const PlayerNetworkSource *source) {
command.sourceDuration = source->duration;
command.network = true;
command.live = source->is_live;
command.mediaURL = source->media_url ? source->media_url : "";
command.initialPosition = source->initial_position;
return player->enqueue(std::move(command));
});
}
+15 -1
View File
@@ -15,7 +15,8 @@ typedef struct PlayerState {
PlayerStatus status;
double position;
double duration;
// Retained playable network media ahead, on the same timeline as position.
// Contiguous retained/playable network range on the position timeline.
// The start may include reusable rewind history; the end includes read-ahead.
// Empty/unavailable (including local playback and pending seeks) is 0, 0.
double buffered_start;
double buffered_end;
@@ -36,6 +37,13 @@ typedef struct PlayerState {
// Playback commands are asynchronous. False means invalid input or a full command queue.
// Paths are copied before returning. All other calls require a live handle.
Player *player_create(void);
// Opt-in reporting must be initialized before opening media. Creates a new 0600
// file exclusively; errors never change playback state. Error buffers hold 512 bytes.
bool player_set_progress_file(Player *player, const char *path, char *error);
bool player_get_progress_error(Player *player, char *error);
// Stops the worker and drains final reporting, allowing errors to be shown before
// destroying the handle. Idempotent; do not call from an audio callback.
bool player_shutdown(Player *player, char *progress_error);
typedef struct PlayerNetworkSource {
// Primary video or combined audio/video HTTP(S) URL; audio_url is optional.
const char *video_url;
@@ -45,6 +53,12 @@ typedef struct PlayerNetworkSource {
const char *audio_headers;
double duration;
bool is_live;
// Canonical YouTube webpage identity, never an extracted stream URL.
// NULL/empty means no reporting for this source.
const char *media_url;
// Initial restored position, applied before publishing ready state. Zero
// starts at the beginning; out-of-duration bookmarks also start at zero.
double initial_position;
} PlayerNetworkSource;
bool player_open(Player *player, const char *path);
// Streams directly; never downloads or muxes the entire media file before opening.
+234
View File
@@ -0,0 +1,234 @@
#include "progress_reporter.hpp"
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <limits>
#include <stdexcept>
#include <sys/stat.h>
#include <unistd.h>
#include <utility>
namespace playback {
namespace {
double monotonicSeconds() {
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
void appendNumber(std::string &record, double value) {
char buffer[64];
auto result = std::to_chars(buffer, buffer + sizeof(buffer), value,
std::chars_format::general, std::numeric_limits<double>::max_digits10);
if (result.ec != std::errc{}) throw std::runtime_error("Could not encode progress timestamp");
record.append(buffer, result.ptr);
}
}
ProgressReporter::ProgressReporter(const std::string &path, std::function<double()> clock)
: clock(clock ? std::move(clock) : monotonicSeconds) {
if (path.empty() || path.find('\0') != std::string::npos)
throw std::invalid_argument("The progress file path must be nonempty and contain no NUL bytes");
descriptor = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
if (descriptor < 0) {
const int code = errno;
throw std::runtime_error("Could not exclusively create progress file '" + path + "': " + std::strerror(code));
}
try {
struct stat info{};
if (::fstat(descriptor, &info) != 0)
throw std::runtime_error(std::string("Could not inspect progress file: ") + std::strerror(errno));
if (!S_ISREG(info.st_mode) || info.st_uid != ::geteuid() || info.st_nlink != 1)
throw std::runtime_error("The new progress file must be a regular file privately owned by this user");
// Creation can only remove owner permissions through umask; restore exactly 0600.
if (::fchmod(descriptor, 0600) != 0)
throw std::runtime_error(std::string("Could not make progress file private: ") + std::strerror(errno));
if (::fstat(descriptor, &info) != 0)
throw std::runtime_error(std::string("Could not verify progress file permissions: ") + std::strerror(errno));
if ((info.st_mode & 07777) != 0600)
throw std::runtime_error("The progress file must have exactly owner read/write permissions (0600)");
writer = std::thread([this] { writeRecords(); });
} catch (...) {
::close(descriptor);
descriptor = -1;
throw;
}
}
ProgressReporter::~ProgressReporter() { finish(); }
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
constexpr std::string_view prefix = "https://www.youtube.com/watch?v=";
if (url.size() != prefix.size() + 11 || url.substr(0, prefix.size()) != prefix) return false;
for (char character : url.substr(prefix.size())) {
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '_' || character == '-')) return false;
}
return true;
}
void ProgressReporter::begin(const std::string &url, bool isLive) noexcept {
if (finished || disabled.load(std::memory_order_acquire)) return;
if (url.empty()) { closeMedia(); return; }
if (!validMediaURL(url)) {
disable("Progress media URL is not a canonical YouTube watch URL");
return;
}
try {
if (mediaURL != url) {
closeMedia();
mediaURL = url;
}
live = isLive;
awaitingReady = true;
seekPending = false;
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("Could not begin playback progress reporting");
}
}
void ProgressReporter::observe(const PlayerState &state) noexcept {
if (finished || mediaURL.empty() || disabled.load(std::memory_order_acquire)) return;
if (state.seek_pending) { seekPending = true; return; }
if (state.status != PLAYER_PAUSED && state.status != PLAYER_PLAYING && state.status != PLAYER_ENDED) return;
if (!std::isfinite(state.position) || state.position < 0) return;
// A later bookmark reset must not replace the native end-of-stream snapshot.
if (hasSnapshot && snapshot.status == PLAYER_ENDED && state.status == PLAYER_ENDED &&
!awaitingReady && !seekPending) return;
const bool paused = hasSnapshot && snapshot.status == PLAYER_PLAYING && state.status == PLAYER_PAUSED;
const double duration = std::isfinite(state.duration) && state.duration > 0 ? state.duration : 0;
const bool eligible = !live && state.has_video && state.can_seek && duration > 0;
const bool crossing = !thresholdReported && eligible && state.position / duration >= .95;
const bool immediate = awaitingReady || seekPending || paused || crossing || state.status == PLAYER_ENDED;
snapshot = {state.position == 0 ? 0 : state.position, duration, state.status, eligible};
hasSnapshot = true;
awaitingReady = false;
seekPending = false;
if (!immediate && state.status != PLAYER_PLAYING) return;
try {
const double now = clock();
if (!std::isfinite(now)) {
disable("The playback progress clock returned a non-finite value");
return;
}
if (immediate || now - lastRecordAt >= 5) {
if (emit(state.status == PLAYER_ENDED ? "ended" : "progress")) {
lastRecordAt = now;
if (crossing) thresholdReported = true;
}
}
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("Could not sample the playback progress clock");
}
}
void ProgressReporter::closeMedia() noexcept {
if (mediaURL.empty()) return;
if (hasSnapshot && !disabled.load(std::memory_order_acquire)) emit("closed");
mediaURL.clear();
hasSnapshot = false;
awaitingReady = false;
seekPending = false;
thresholdReported = false;
lastRecordAt = 0;
}
bool ProgressReporter::emit(const char *event) noexcept {
try {
std::string record;
record.reserve(256);
record += "{\"version\":1,\"event\":\"";
record += event;
record += "\",\"media_url\":\"";
record += mediaURL;
record += "\",\"position_seconds\":";
appendNumber(record, snapshot.position);
record += ",\"duration_seconds\":";
if (snapshot.duration > 0) appendNumber(record, snapshot.duration);
else record += "null";
record += ",\"completion_eligible\":";
record += snapshot.eligible ? "true}\n" : "false}\n";
{
std::lock_guard lock(mutex);
if (stopping || disabled.load(std::memory_order_relaxed)) return false;
records.push_back(std::move(record));
}
wake.notify_one();
return true;
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("Could not encode or queue playback progress");
}
return false;
}
void ProgressReporter::disable(const char *message, int osError) noexcept {
std::lock_guard lock(mutex);
if (!failure[0]) {
if (osError) std::snprintf(failure.data(), failure.size(), "%s: %s", message, std::strerror(osError));
else std::snprintf(failure.data(), failure.size(), "%s", message);
if (!failure[0]) std::snprintf(failure.data(), failure.size(), "Playback progress reporting failed");
}
disabled.store(true, std::memory_order_release);
records.clear();
wake.notify_all();
}
std::string ProgressReporter::error() const {
std::lock_guard lock(mutex);
return failure.data();
}
void ProgressReporter::writeRecords() noexcept {
try {
for (;;) {
std::string record;
{
std::unique_lock lock(mutex);
wake.wait(lock, [this] { return stopping || disabled.load(std::memory_order_relaxed) || !records.empty(); });
if (disabled.load(std::memory_order_relaxed) || (stopping && records.empty())) break;
record = std::move(records.front());
records.pop_front();
}
std::size_t written = 0;
while (written < record.size() && !disabled.load(std::memory_order_acquire)) {
const auto count = ::write(descriptor, record.data() + written, record.size() - written);
if (count > 0) written += static_cast<std::size_t>(count);
else if (count < 0 && errno == EINTR) continue;
else {
const int code = count < 0 ? errno : EIO;
disable("Could not write playback progress file", code);
break;
}
}
if (disabled.load(std::memory_order_acquire)) break;
}
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("The playback progress writer failed");
}
if (::close(descriptor) != 0) disable("Could not close playback progress file", errno);
descriptor = -1;
}
void ProgressReporter::finish() noexcept {
if (finished) return;
closeMedia();
finished = true;
{
std::lock_guard lock(mutex);
stopping = true;
}
wake.notify_all();
if (writer.joinable()) writer.join();
}
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include "player.h"
#include <array>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <string_view>
#include <thread>
namespace playback {
// The decoder owns media/snapshot methods; the writer owns the file after construction.
class ProgressReporter {
public:
explicit ProgressReporter(const std::string &path, std::function<double()> clock = {});
~ProgressReporter();
ProgressReporter(const ProgressReporter &) = delete;
ProgressReporter &operator=(const ProgressReporter &) = delete;
static bool validMediaURL(std::string_view url) noexcept;
void begin(const std::string &mediaURL, bool live = false) noexcept;
void observe(const PlayerState &state) noexcept;
void closeMedia() noexcept;
// Call after the decoder joins; drains queued records and joins the writer.
void finish() noexcept;
std::string error() const;
private:
struct Snapshot {
double position = 0;
double duration = 0;
PlayerStatus status = PLAYER_EMPTY;
bool eligible = false;
} snapshot;
std::string mediaURL;
std::function<double()> clock;
bool live = false;
bool hasSnapshot = false;
bool awaitingReady = false;
bool seekPending = false;
bool thresholdReported = false;
bool finished = false;
double lastRecordAt = 0;
int descriptor = -1;
std::thread writer;
mutable std::mutex mutex;
std::condition_variable wake;
std::deque<std::string> records;
std::array<char, 512> failure{};
std::atomic<bool> disabled{false};
bool stopping = false;
bool emit(const char *event) noexcept;
void disable(const char *message, int osError = 0) noexcept;
void writeRecords() noexcept;
};
}