Compare commits
40 Commits
31c4759c1d
...
f4fec676a8
| Author | SHA1 | Date | |
|---|---|---|---|
| f4fec676a8 | |||
| 58609e4d99 | |||
| ed9d8c6713 | |||
| b7e176abe7 | |||
| ad4b2da5c8 | |||
| b0636c5af7 | |||
| b9e63cd760 | |||
| 697158aa02 | |||
| bb777b0ecd | |||
| 74b4bfe6c1 | |||
| 4ef8ceee24 | |||
| 51382bc63a | |||
| d1f0d83e4c | |||
| 1aa5454194 | |||
| e66f45d9ef | |||
| 2398370c23 | |||
| 7d24ce609e | |||
| 0509eee28e | |||
| 0501969134 | |||
| fe822fb381 | |||
| 470d8feec2 | |||
| 1f12660936 | |||
| 750c20507c | |||
| daac27be8d | |||
| 35fa89e492 | |||
| 1f5e9541e7 | |||
| 3d897c3a05 | |||
| 30cf85e880 | |||
| b2dc5fa373 | |||
| aacd260fe7 | |||
| e3e6a66216 | |||
| 2a75d55c98 | |||
| 7aa6e5f805 | |||
| 93acc1436a | |||
| 0ed35b4a58 | |||
| e6897332a5 | |||
| 39efa96986 | |||
| 1e75f554f0 | |||
| f495c8b719 | |||
| e54cb2b549 |
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Terebi playback
|
||||
|
||||
Terebi plays local media and YouTube videos. These terms distinguish a playback request from the state the player has actually reached.
|
||||
Terebi plays local media, YouTube videos and resolved Miruro episodes. These terms distinguish a playback request from the state the player has actually reached.
|
||||
|
||||
## Language
|
||||
|
||||
@@ -17,4 +17,4 @@ The playback position confirmed by the player after any pending seek or restorat
|
||||
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.
|
||||
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. For a Miruro episode, this is its opaque catalog media ID and episode number, independent of provider and expiring source URL.
|
||||
|
||||
+120
-9
@@ -11,7 +11,29 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from streaming import StreamingServer
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from streaming import StreamingServer, resolved_fixtures
|
||||
|
||||
|
||||
class StartupStreamingServer(StreamingServer):
|
||||
def __init__(self, fixtures):
|
||||
super().__init__(fixtures)
|
||||
self.subtitle_requests = [0, 0]
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
if parsed.path in ("/startup-0.vtt", "/startup-1.vtt"):
|
||||
index = int(parsed.path[len("/startup-")])
|
||||
if parsed.query != self.resolved_token or request.headers.get("X-Subtitle") != f"track-{index}":
|
||||
self._reply(request, 403, b"Missing subtitle credentials")
|
||||
return
|
||||
with self.lock:
|
||||
self.subtitle_requests[index] += 1
|
||||
body = f"WEBVTT\n\n00:00:00.000 --> 00:01:00.000\nIndependent subtitle {index}\n".encode()
|
||||
self._reply(request, 200, body, "text/vtt")
|
||||
return
|
||||
super()._get(request)
|
||||
|
||||
|
||||
def check_cli_instances(root, app, fixtures):
|
||||
@@ -87,13 +109,15 @@ def check_cli_instances(root, app, fixtures):
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
records = [json.loads(line) for line in lines if line]
|
||||
fields = {"version", "event", "media_url", "position_seconds", "duration_seconds", "completion_eligible"}
|
||||
miruro = isinstance(allowed, list)
|
||||
identity_field = "media_identity" if miruro else "media_url"
|
||||
fields = {"version", "event", identity_field, "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
|
||||
valid = (set(record) == fields and record["version"] == (2 if miruro else 1)
|
||||
and record["event"] in ("progress", "ended", "closed")
|
||||
and record["media_url"] in allowed
|
||||
and record[identity_field] 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))
|
||||
@@ -125,20 +149,98 @@ def check_cli_instances(root, app, fixtures):
|
||||
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")
|
||||
def quit_instance(pid, count, events=None):
|
||||
if events is None:
|
||||
write_request(pid, action="quit")
|
||||
else:
|
||||
write_request(pid, events=events)
|
||||
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):
|
||||
exited = False
|
||||
if count == 1:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
exited = True
|
||||
if exited or (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:
|
||||
def check_resolved_instances(server, count):
|
||||
originals = wait_states(count, lambda _: True, "existing players are ready before resolved launches")
|
||||
states = originals
|
||||
resolved_pids = []
|
||||
resolved_reports = []
|
||||
identities = []
|
||||
for index in range(2):
|
||||
source = root / f"resolved source {index}.json"
|
||||
report = root / f"resolved progress {index}.jsonl"
|
||||
identity = {"provider": "miruro", "media_id": "startup_opaque", "episode": index + 1}
|
||||
source.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"url": server.base_url + "/resolved/encrypted/master.m3u8?" + server.resolved_token,
|
||||
"headers": {"X-Terebi-Fixture": "combined", "Authorization": "Bearer " + server.resolved_secret},
|
||||
"identity": identity, "title": f"Independent resolved episode {index + 1}",
|
||||
"subtitles": [{"url": server.base_url + f"/startup-{index}.vtt?" + server.resolved_token,
|
||||
"format": "vtt", "language": "en", "label": f"Track {index}",
|
||||
"default": True, "headers": {"X-Subtitle": f"track-{index}"}}]
|
||||
}))
|
||||
source.chmod(0o600)
|
||||
previous = set(states)
|
||||
invoke(["--new-instance", "--source-file", str(source), "--progress-file", str(report)])
|
||||
states = wait_states(count + index + 1, lambda snapshots: any(
|
||||
pid not in previous and state["media"] == f"Independent resolved episode {index + 1}"
|
||||
and state["status"] == 3 and state["hasVideo"] and state["hasAudio"]
|
||||
and state["canSeek"] and not state["busy"] and not state["seekPending"]
|
||||
and state["subtitleLabels"] == [f"Track {index}"] and state["selectedSubtitle"] == 0
|
||||
and state["subtitleText"] == f"Independent subtitle {index}" and not state["subtitleError"]
|
||||
for pid, state in snapshots.items()), "actual source-file CLI reaches registered GUI HLS and VTT playback")
|
||||
pid = next(pid for pid in states if pid not in previous)
|
||||
states = input_request(pid, count + index + 1, lambda state: state["status"] == 2,
|
||||
"resolved GUI pauses independently after decoded startup", action="pause")
|
||||
states = input_request(pid, count + index + 1,
|
||||
lambda state: state["selectedSubtitle"] == -1 and not state["subtitleText"],
|
||||
"visible native Off item disables rendered captions", action="caption", label="Off")
|
||||
states = input_request(pid, count + index + 1,
|
||||
lambda state: state["selectedSubtitle"] == 0
|
||||
and state["subtitleText"] == f"Independent subtitle {index}",
|
||||
"visible native track item restores rendered captions",
|
||||
action="caption", label=f"Track {index} (en)")
|
||||
wait_report(report, [identity], lambda rows: any(
|
||||
row["event"] == "progress" and row["completion_eligible"] for row in rows),
|
||||
"resolved GUI owns v2 stable-identity reporting after CLI exit")
|
||||
resolved_pids.append(pid)
|
||||
resolved_reports.append(report)
|
||||
identities.append(identity)
|
||||
for pid, original in originals.items():
|
||||
if states[pid]["media"] != original["media"] or settings(states[pid]) != settings(original):
|
||||
raise RuntimeError("resolved launches disturbed an existing player")
|
||||
with server.lock:
|
||||
stats = server.resolved_stats.get("encrypted", {})
|
||||
if any(server.subtitle_requests[index] == 0 for index in range(2)) or any(
|
||||
stats.get(kind, {}).get("requests", 0) == 0 or stats[kind]["denied"]
|
||||
for kind in ("master", "variant", "key", "segment")):
|
||||
raise RuntimeError("actual CLI playback lost complete HLS URLs, credentials, or independent VTT headers")
|
||||
for index in reversed(range(2)):
|
||||
pid = resolved_pids[index]
|
||||
quit_instance(pid, count + index + 1)
|
||||
wait_report(resolved_reports[index], [identities[index]],
|
||||
lambda rows: bool(rows) and rows[-1]["event"] == "closed",
|
||||
"resolved GUI quit drains only its stable episode identity")
|
||||
report_bytes = resolved_reports[index].read_bytes()
|
||||
if not report_bytes.endswith(b"\n") or any(secret in report_bytes for secret in (
|
||||
server.resolved_token.encode(), server.resolved_secret.encode(), server.base_url.encode())):
|
||||
raise RuntimeError("resolved report is incomplete or exposes transport secrets")
|
||||
wait_states(count, lambda _: True, "original players survive independent resolved GUI quits")
|
||||
print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit")
|
||||
|
||||
resolved_fixtures(fixtures)
|
||||
with StartupStreamingServer(fixtures) as server:
|
||||
if extractor and runtime:
|
||||
choices = []
|
||||
for index, url in enumerate([canonical, *reported_urls[:3]]):
|
||||
@@ -171,6 +273,9 @@ def check_cli_instances(root, app, fixtures):
|
||||
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))
|
||||
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
|
||||
if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1":
|
||||
check_resolved_instances(server, 0)
|
||||
return
|
||||
movie = fixtures / "cache.mp4"
|
||||
audio = root / "audio with spaces.wav"
|
||||
shutil.copy2(fixtures / "audio.wav", audio)
|
||||
@@ -280,6 +385,7 @@ def check_cli_instances(root, app, fixtures):
|
||||
else:
|
||||
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
|
||||
|
||||
check_resolved_instances(server, count)
|
||||
existing_report = root / "existing progress.jsonl"
|
||||
sentinel = b"existing destination must remain byte-for-byte unchanged\n"
|
||||
existing_report.write_bytes(sentinel)
|
||||
@@ -430,7 +536,12 @@ def check_cli_instances(root, app, fixtures):
|
||||
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)
|
||||
input_request(second_reporter, total, lambda state: state["editorText"] == "q",
|
||||
"plain q remains text input rather than Quit",
|
||||
events=[{"key": "q", "code": 12, "editing": True}])
|
||||
quit_instance(second_reporter, total, events=[
|
||||
{"key": "q", "code": 12, "flags": 1 << 20, "editing": True, "queued": True},
|
||||
])
|
||||
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,
|
||||
|
||||
@@ -90,6 +90,14 @@ private enum CLIInstanceCheck {
|
||||
}
|
||||
}
|
||||
|
||||
static func captionSelector(in view: NSView) -> NSPopUpButton? {
|
||||
if let selector = view as? NSPopUpButton, selector.toolTip == "Subtitles" { return selector }
|
||||
for child in view.subviews {
|
||||
if let selector = captionSelector(in: child) { return selector }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -139,6 +147,20 @@ private enum CLIInstanceCheck {
|
||||
switch action {
|
||||
case "youtube":
|
||||
model.openYouTube(command["url"] as! String)
|
||||
case "caption":
|
||||
guard let view = window.contentView, let selector = captionSelector(in: view),
|
||||
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
|
||||
fatalError("Native captions selector is missing")
|
||||
}
|
||||
cell.attachPopUp(withFrame: selector.bounds, in: selector)
|
||||
menu.update()
|
||||
let label = command["label"] as! String
|
||||
guard let item = menu.items.first(where: { $0.title == label }) else {
|
||||
fatalError("Requested native subtitle selection is missing")
|
||||
}
|
||||
precondition(!item.isHidden && item.isEnabled, "Subtitle selection must be visible and enabled")
|
||||
cell.dismissPopUp()
|
||||
precondition(selectMenuItem(label, in: menu), "Native subtitle selection must be actionable")
|
||||
case "seek":
|
||||
let position = command["position"] as! Double
|
||||
precondition(model.seekRelative(position - model.state.position), "Native seek must be accepted")
|
||||
@@ -184,8 +206,10 @@ private enum CLIInstanceCheck {
|
||||
if let menu = NSApp.mainMenu { prepareMenu(menu) }
|
||||
precondition(NSApp.mainMenu?.performKeyEquivalent(with: event) == true,
|
||||
"Configured shortcut must be accepted by the native menu")
|
||||
} else if input["queued"] as? Bool == true {
|
||||
NSApp.postEvent(event, atStart: false)
|
||||
} else { NSApp.sendEvent(event) }
|
||||
if input["editing"] as? Bool == true {
|
||||
if input["editing"] as? Bool == true && input["queued"] as? Bool != true {
|
||||
editorText = editor.string
|
||||
window.makeFirstResponder(nil)
|
||||
editor.removeFromSuperview()
|
||||
@@ -214,6 +238,10 @@ private enum CLIInstanceCheck {
|
||||
"hasAudio": model.state.has_audio,
|
||||
"hasVideo": model.state.has_video,
|
||||
"error": model.errorText ?? "",
|
||||
"subtitleLabels": model.subtitleTracks.map(\.label),
|
||||
"selectedSubtitle": model.selectedSubtitle ?? -1,
|
||||
"subtitleText": model.subtitleText,
|
||||
"subtitleError": model.subtitleError ?? "",
|
||||
"showBufferInfo": model.showsBufferInfo,
|
||||
"automaticQuality": model.automaticYouTubeQuality,
|
||||
"snapToEdges": model.compactWindow.snappingEnabled,
|
||||
|
||||
@@ -132,7 +132,7 @@ int main(int argc, char **argv) {
|
||||
require(argc == 2, "fixture directory required");
|
||||
require(!player_set_volume(nullptr, .5f) && player_get_volume(nullptr) == 0, "null volume handle");
|
||||
std::string directory = argv[1];
|
||||
for (const char *name : {"tone441.wav", "tone480.wav", "tone441.mka", "tone-aac.m4a"}) {
|
||||
for (const char *name : {"tone441.wav", "tone480.wav", "tone441.mka", "tone-aac.m4a", "tone-jitter.ts"}) {
|
||||
std::string path = directory + "/" + name;
|
||||
std::ifstream input(path + ".f32", std::ios::binary | std::ios::ate);
|
||||
require(bool(input), "PCM reference exists");
|
||||
|
||||
@@ -36,12 +36,66 @@ struct AutomaticQualityPlaybackChecks {
|
||||
let model = PlayerModel(defaults: defaults, qualityClock: { clock.now })
|
||||
defer { model.shutdown() }
|
||||
model.setVolume(0)
|
||||
// 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)
|
||||
}
|
||||
require(!model.automaticYouTubeQuality, "automatic quality is opt-in")
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
model.attachWindow(window)
|
||||
let normalFrame = window.frame
|
||||
let compactKey = try! KeyChord("p")
|
||||
mark("gate-open")
|
||||
model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
|
||||
until(model, "initial YouTube resolution reaches the controlled extraction gate") {
|
||||
model.resolvingYouTube && marked("entered-open")
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
require(model.isCompact, "compact shortcut enters while the YouTube URL is unresolved")
|
||||
until(model, "compact mode remains usable across empty playback polls while resolving") {
|
||||
model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
require(window.level == .floating && window.frame.width < normalFrame.width,
|
||||
"unresolved video has a usable compact window")
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut restores while YouTube resolution is still pending") {
|
||||
!model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
require(window.frame == normalFrame && model.resolvingYouTube,
|
||||
"resolution continues while restoring the original normal window")
|
||||
require(model.perform(.enterCompact), "compact control action is available during resolution")
|
||||
mark("release-open")
|
||||
try! FileManager.default.removeItem(at: control.appendingPathComponent("gate-open"))
|
||||
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
|
||||
}
|
||||
require(model.isCompact, "loading completion preserves the requested compact mode")
|
||||
until(model, "decoded metadata updates compact geometry") {
|
||||
!model.compactWindow.isTransitioning &&
|
||||
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut restores during network buffering") {
|
||||
!model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut re-enters during network buffering") {
|
||||
model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
clock.now = 2000
|
||||
polled(model)
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingYouTube,
|
||||
@@ -75,19 +129,6 @@ struct AutomaticQualityPlaybackChecks {
|
||||
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 &&
|
||||
@@ -149,6 +190,8 @@ struct AutomaticQualityPlaybackChecks {
|
||||
"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")
|
||||
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
||||
"closing a streamed video restores its original window mode")
|
||||
print("PASS native adaptive playback: resolving/loading/buffering compact shortcuts+controls/default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings/cancelled extraction pause/failed manual quality playing+paused preservation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ def main():
|
||||
control.mkdir()
|
||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
|
||||
+ "control=" + shlex.quote(str(control)) + "\n"
|
||||
+ 'if [[ -f "$control/gate-open" ]]; then\n'
|
||||
+ ' : > "$control/entered-open"\n'
|
||||
+ ' while [[ -f "$control/gate-open" && ! -f "$control/release-open" ]]; do sleep 0.01; done\n'
|
||||
+ 'fi\n'
|
||||
+ 'if [[ "$*" == *"[height=1080]"* ]]; then\n'
|
||||
+ ' if [[ -f "$control/fail-1080" ]]; then\n'
|
||||
+ ' echo "Fixture extraction deliberately failed" >&2; exit 1\n'
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
#include "cast_media.h"
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavcodec/bsf.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/opt.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
extern char **environ;
|
||||
|
||||
static void ok(int result) { assert(result >= 0); }
|
||||
static std::vector<uint8_t> readFile(const std::string &path) {
|
||||
std::ifstream file(path, std::ios::binary); assert(file.good());
|
||||
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
static void writeFile(const std::string &path, const std::vector<uint8_t> &bytes) {
|
||||
std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast<const char *>(bytes.data()), bytes.size()); assert(file.good());
|
||||
}
|
||||
// Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures.
|
||||
static void fixture(const std::string &path, int seconds = 90, int width = 96, int height = 64, int bitrate = 300000) {
|
||||
AVFormatContext *format = nullptr; ok(avformat_alloc_output_context2(&format, nullptr, "mpegts", path.c_str()));
|
||||
AVCodecContext *video = avcodec_alloc_context3(avcodec_find_encoder_by_name("h264_videotoolbox"));
|
||||
AVCodecContext *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio);
|
||||
video->width = width; video->height = height; video->pix_fmt = AV_PIX_FMT_NV12;
|
||||
video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = bitrate; video->gop_size = 70; video->max_b_frames = 0;
|
||||
video->sample_aspect_ratio = {4, 3};
|
||||
av_opt_set(video->priv_data, "allow_sw", "1", 0); av_opt_set(video->priv_data, "realtime", "1", 0);
|
||||
audio->sample_rate = 48000; audio->sample_fmt = AV_SAMPLE_FMT_FLTP; audio->time_base = {1, 48000}; audio->bit_rate = 128000;
|
||||
av_channel_layout_default(&audio->ch_layout, 2);
|
||||
ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr));
|
||||
auto *vs = avformat_new_stream(format, nullptr), *as = avformat_new_stream(format, nullptr); assert(vs && as);
|
||||
vs->time_base = video->time_base; as->time_base = audio->time_base;
|
||||
ok(avcodec_parameters_from_context(vs->codecpar, video)); ok(avcodec_parameters_from_context(as->codecpar, audio));
|
||||
AVBSFContext *aspect = nullptr;
|
||||
ok(av_bsf_alloc(av_bsf_get_by_name("h264_metadata"), &aspect));
|
||||
ok(avcodec_parameters_copy(aspect->par_in, vs->codecpar));
|
||||
aspect->time_base_in = video->time_base;
|
||||
ok(av_opt_set(aspect->priv_data, "sample_aspect_ratio", "4/3", 0));
|
||||
ok(av_bsf_init(aspect));
|
||||
ok(avio_open(&format->pb, path.c_str(), AVIO_FLAG_WRITE)); ok(avformat_write_header(format, nullptr));
|
||||
auto encode = [&](AVCodecContext *codec, AVStream *stream, AVFrame *frame) {
|
||||
ok(avcodec_send_frame(codec, frame)); auto *packet = av_packet_alloc(); assert(packet);
|
||||
for (;;) {
|
||||
int result = avcodec_receive_packet(codec, packet);
|
||||
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break;
|
||||
ok(result);
|
||||
if (codec == video) {
|
||||
ok(av_bsf_send_packet(aspect, packet));
|
||||
while (av_bsf_receive_packet(aspect, packet) >= 0) {
|
||||
av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index;
|
||||
ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet);
|
||||
}
|
||||
} else {
|
||||
av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index;
|
||||
ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet);
|
||||
}
|
||||
}
|
||||
av_packet_free(&packet);
|
||||
};
|
||||
int64_t vi = 0, ai = 0;
|
||||
while (vi < seconds * 10 || ai < seconds * 48000) {
|
||||
bool isVideo = vi < seconds * 10 && (ai >= seconds * 48000 || vi / 10.0 <= ai / 48000.0);
|
||||
auto *f = av_frame_alloc(); assert(f);
|
||||
if (isVideo) {
|
||||
f->width = width; f->height = height; f->format = video->pix_fmt; f->pts = vi++;
|
||||
ok(av_frame_get_buffer(f, 32));
|
||||
for (int y = 0; y < height; ++y) std::memset(f->data[0] + y * f->linesize[0], 40 + vi % 160, width);
|
||||
for (int y = 0; y < height / 2; ++y) std::memset(f->data[1] + y * f->linesize[1], 128, width);
|
||||
encode(video, vs, f);
|
||||
} else {
|
||||
f->sample_rate = 48000; f->format = audio->sample_fmt; f->nb_samples = audio->frame_size;
|
||||
ok(av_channel_layout_copy(&f->ch_layout, &audio->ch_layout)); f->pts = ai;
|
||||
ok(av_frame_get_buffer(f, 0));
|
||||
for (int channel = 0; channel < 2; ++channel) for (int n = 0; n < f->nb_samples; ++n)
|
||||
reinterpret_cast<float *>(f->data[channel])[n] = float(.1 * std::sin((ai + n) * 440 * 2 * 3.141592653589793 / 48000));
|
||||
ai += f->nb_samples; encode(audio, as, f);
|
||||
}
|
||||
av_frame_free(&f);
|
||||
}
|
||||
encode(video, vs, nullptr); encode(audio, as, nullptr); ok(av_write_trailer(format));
|
||||
av_bsf_free(&aspect);
|
||||
avio_closep(&format->pb); avformat_free_context(format); avcodec_free_context(&video); avcodec_free_context(&audio);
|
||||
}
|
||||
// Indexed split MP4 is the resolved YouTube VOD shape. As with check-fixtures,
|
||||
// the check requires host ffmpeg for MP4 muxing; production uses only bundled libraries.
|
||||
static void splitFixture(const std::string &source, const std::string &path, AVMediaType type) {
|
||||
const char *arguments[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", source.c_str(), "-map", type == AVMEDIA_TYPE_VIDEO ? "0:v:0" : "0:a:0",
|
||||
"-c", "copy", "-movflags", "+faststart", path.c_str(), nullptr};
|
||||
pid_t child = 0;
|
||||
int result = posix_spawnp(&child, arguments[0], nullptr, nullptr,
|
||||
const_cast<char *const *>(arguments), environ);
|
||||
if (result != 0) std::cerr << "cast-media split fixture requires host ffmpeg (same as make check-fixtures)\n";
|
||||
assert(result == 0);
|
||||
int status = 0; assert(waitpid(child, &status, 0) == child);
|
||||
assert(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
static std::map<std::string, std::vector<uint8_t>> hlsFixture(const std::string &source, const std::string &unusedSource, const std::filesystem::path &directory) {
|
||||
std::filesystem::create_directory(directory);
|
||||
for (const auto &variant : {std::pair{"selected", source}, std::pair{"unused", unusedSource}}) {
|
||||
const auto playlist = (directory / (std::string(variant.first) + ".m3u8")).string();
|
||||
const auto segments = (directory / (std::string(variant.first) + "-%03d.ts")).string();
|
||||
const char *arguments[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", variant.second.c_str(), "-map", "0", "-c", "copy", "-hls_time", "6",
|
||||
"-hls_list_size", "0", "-hls_segment_filename", segments.c_str(), playlist.c_str(), nullptr};
|
||||
pid_t child = 0;
|
||||
assert(posix_spawnp(&child, arguments[0], nullptr, nullptr, const_cast<char *const *>(arguments), environ) == 0);
|
||||
int status = 0; assert(waitpid(child, &status, 0) == child);
|
||||
assert(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
std::map<std::string, std::vector<uint8_t>> resources;
|
||||
for (const auto &entry : std::filesystem::directory_iterator(directory))
|
||||
resources["/hls/" + entry.path().filename().string()] = readFile(entry.path().string());
|
||||
const std::string master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=96x64,CODECS=\"avc1.64000a,mp4a.40.2\"\nselected.m3u8\n"
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=48x32,CODECS=\"avc1.64000a,mp4a.40.2\"\nunused.m3u8\n";
|
||||
resources["/hls/master.m3u8"] = {master.begin(), master.end()};
|
||||
return resources;
|
||||
}
|
||||
static std::vector<uint8_t> wav() {
|
||||
std::vector<uint8_t> bytes(44 + 90 * 48000 * 2);
|
||||
auto put = [&](size_t at, uint32_t value, int length) { for (int i = 0; i < length; ++i) bytes[at + i] = uint8_t(value >> (8 * i)); };
|
||||
std::memcpy(bytes.data(), "RIFF", 4); put(4, uint32_t(bytes.size() - 8), 4); std::memcpy(bytes.data() + 8, "WAVEfmt ", 8);
|
||||
put(16, 16, 4); put(20, 1, 2); put(22, 1, 2); put(24, 48000, 4); put(28, 96000, 4); put(32, 2, 2); put(34, 16, 2);
|
||||
std::memcpy(bytes.data() + 36, "data", 4); put(40, uint32_t(bytes.size() - 44), 4);
|
||||
for (size_t i = 0; i < 90 * 48000; ++i) put(44 + i * 2, uint16_t(int16_t(4000 * std::sin(i * 440 * 2 * 3.141592653589793 / 48000))), 2);
|
||||
return bytes;
|
||||
}
|
||||
struct HTTP {
|
||||
int status = 0; std::string headers; std::vector<uint8_t> bytes;
|
||||
};
|
||||
static HTTP request(const std::string &url, const std::string &method = "GET", const std::string &headers = "", bool allowClosed = false, std::promise<void> *sent = nullptr) {
|
||||
assert(url.starts_with("http://127.0.0.1:")); size_t slash = url.find('/', 7), colon = url.find(':', 7);
|
||||
sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); address.sin_port = htons(std::stoi(url.substr(colon + 1, slash - colon - 1)));
|
||||
int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(socket >= 0);
|
||||
timeval timeout{60, 0}; setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
assert(connect(socket, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0);
|
||||
std::string text = method + " " + url.substr(slash) + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + headers + "Connection: close\r\n\r\n";
|
||||
assert(send(socket, text.data(), text.size(), 0) == ssize_t(text.size()));
|
||||
if (sent) sent->set_value();
|
||||
std::vector<uint8_t> bytes; uint8_t buffer[65536];
|
||||
for (;;) {
|
||||
ssize_t n = recv(socket, buffer, sizeof(buffer), 0);
|
||||
if (n < 0 && allowClosed) break;
|
||||
assert(n >= 0); if (!n) break;
|
||||
bytes.insert(bytes.end(), buffer, buffer + n);
|
||||
}
|
||||
close(socket); std::string raw(bytes.begin(), bytes.end()); size_t end = raw.find("\r\n\r\n");
|
||||
if (allowClosed && end == std::string::npos) return {};
|
||||
assert(end != std::string::npos);
|
||||
return {std::stoi(raw.substr(9, 3)), raw.substr(0, end + 4), {bytes.begin() + end + 4, bytes.end()}};
|
||||
}
|
||||
// A tiny real upstream tests resource-specific private headers and byte-range seek.
|
||||
struct Upstream {
|
||||
int listener; uint16_t port; std::thread threads[4]; std::atomic<bool> stopped{false};
|
||||
std::atomic<int> foreignRequests{0}, videoRequests{0}, audioRequests{0};
|
||||
std::atomic<int> videoStarts{0}, audioStarts{0};
|
||||
std::map<std::string, std::vector<uint8_t>> resources;
|
||||
std::mutex mutex;
|
||||
std::condition_variable gate;
|
||||
std::map<std::string, int> mediaRequests;
|
||||
bool gated = false;
|
||||
int held = 0, released = 0;
|
||||
std::map<std::string, int> counts() {
|
||||
std::lock_guard lock(mutex);
|
||||
return mediaRequests;
|
||||
}
|
||||
std::string foreign;
|
||||
explicit Upstream(std::vector<uint8_t> video, std::vector<uint8_t> audio) {
|
||||
resources["/source.ts"] = std::move(video); resources["/audio.wav"] = std::move(audio);
|
||||
resources["/public.ts"] = resources["/source.ts"];
|
||||
listener = socket(AF_INET, SOCK_STREAM, 0); assert(listener >= 0); int one = 1; setsockopt(listener, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
|
||||
sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
assert(bind(listener, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0); assert(listen(listener, 16) == 0);
|
||||
assert(fcntl(listener, F_SETFL, O_NONBLOCK) == 0);
|
||||
socklen_t length = sizeof(address); assert(getsockname(listener, reinterpret_cast<sockaddr *>(&address), &length) == 0); port = ntohs(address.sin_port);
|
||||
}
|
||||
std::string url(std::string path) const { return "http://127.0.0.1:" + std::to_string(port) + path; }
|
||||
void start() { for (auto &thread : threads) thread = std::thread([&] {
|
||||
while (!stopped) {
|
||||
pollfd ready{listener, POLLIN, 0};
|
||||
if (poll(&ready, 1, 100) <= 0 || !(ready.revents & POLLIN)) continue;
|
||||
int client = accept(listener, nullptr, nullptr); if (client < 0) continue;
|
||||
assert(fcntl(client, F_SETFL, fcntl(client, F_GETFL) & ~O_NONBLOCK) == 0);
|
||||
int one = 1; setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); timeval timeout{5, 0}; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
std::string request; char buffer[2048];
|
||||
while (request.find("\r\n\r\n") == std::string::npos && request.size() < 16384) { auto n = recv(client, buffer, sizeof(buffer), 0); if (n <= 0) break; request.append(buffer, size_t(n)); }
|
||||
size_t begin = request.find(' '), end = request.find(' ', begin + 1); std::string path = request.substr(begin + 1, end - begin - 1);
|
||||
int status = 200; std::string extra; std::vector<uint8_t> data;
|
||||
if (path == "/redirect") { status = 302; extra = "Location: " + foreign + "/source.ts\r\n"; }
|
||||
else if (path == "/cross.m3u8" || path == "/index.m3u8" || path == "/public-cross.m3u8") {
|
||||
std::string playlist = "#EXTM3U\n#EXT-X-TARGETDURATION:90\n#EXTINF:90,\n" +
|
||||
(path == "/index.m3u8" ? url("") : foreign) +
|
||||
(path == "/public-cross.m3u8" ? "/public.ts" : "/source.ts") + "\n#EXT-X-ENDLIST\n";
|
||||
data.assign(playlist.begin(), playlist.end());
|
||||
} else if (auto resource = resources.find(path); resource != resources.end()) {
|
||||
bool audio = path == "/audio.wav" || path == "/audio.m4a";
|
||||
bool publicMedia = path == "/public.ts";
|
||||
bool authorized = publicMedia || request.find(audio ? "X-Audio: audio-secret" : "X-Video: video-secret") != std::string::npos;
|
||||
bool wrong = request.find(publicMedia ? "secret" : (audio ? "video-secret" : "audio-secret")) != std::string::npos;
|
||||
if (!authorized || wrong) { ++foreignRequests; status = 403; }
|
||||
else {
|
||||
if (audio) ++audioRequests; else ++videoRequests;
|
||||
data = resource->second;
|
||||
if (path.starts_with("/hls/") && path.ends_with(".ts") && request.starts_with("GET")) {
|
||||
std::unique_lock lock(mutex);
|
||||
++mediaRequests[path];
|
||||
if (gated) {
|
||||
int ticket = ++held;
|
||||
gate.notify_all();
|
||||
gate.wait(lock, [&] { return released >= ticket || stopped; });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else status = 404;
|
||||
size_t low = 0, high = data.size();
|
||||
auto range = request.find("Range: bytes=");
|
||||
if (range != std::string::npos && !data.empty()) {
|
||||
low = std::stoull(request.substr(range + 13)); auto dash = request.find('-', range);
|
||||
if (dash != std::string::npos && std::isdigit(request[dash + 1])) high = std::min(high, size_t(std::stoull(request.substr(dash + 1))) + 1);
|
||||
if (low >= high) { status = 416; low = high = 0; }
|
||||
else { status = 206; extra += "Content-Range: bytes " + std::to_string(low) + "-" + std::to_string(high - 1) + "/" + std::to_string(data.size()) + "\r\n"; }
|
||||
}
|
||||
if ((status == 200 || status == 206) && low == 0 && resources.contains(path)) {
|
||||
if (path == "/audio.wav" || path == "/audio.m4a") ++audioStarts; else ++videoStarts;
|
||||
}
|
||||
std::string header = "HTTP/1.1 " + std::to_string(status) + " Test\r\nConnection: close\r\nAccept-Ranges: bytes\r\n" + extra + "Content-Length: " + std::to_string(high - low) + "\r\n\r\n";
|
||||
send(client, header.data(), header.size(), 0);
|
||||
if (!request.starts_with("HEAD")) while (high > low) { auto n = send(client, data.data() + low, high - low, 0); if (n <= 0) break; low += size_t(n); }
|
||||
close(client);
|
||||
}
|
||||
}); }
|
||||
~Upstream() {
|
||||
{ std::lock_guard lock(mutex); stopped = true; gate.notify_all(); }
|
||||
shutdown(listener, SHUT_RDWR);
|
||||
for (auto &thread : threads) if (thread.joinable()) thread.join();
|
||||
close(listener);
|
||||
}
|
||||
};
|
||||
// Every media GET really waits at the upstream until this driver releases it.
|
||||
// Deadlines are watchdogs, not media clocks; no elapsed-time sleeps drive the test.
|
||||
template<typename Operation> static auto throughGate(Upstream &upstream, Operation operation) {
|
||||
bool done = false;
|
||||
auto task = std::async(std::launch::async, [&] {
|
||||
auto result = operation();
|
||||
{ std::lock_guard lock(upstream.mutex); done = true; upstream.gate.notify_all(); }
|
||||
return result;
|
||||
});
|
||||
std::unique_lock lock(upstream.mutex);
|
||||
while (!done) {
|
||||
assert(upstream.gate.wait_for(lock, std::chrono::seconds(60), [&] { return done || upstream.held > upstream.released; }));
|
||||
upstream.released = upstream.held;
|
||||
upstream.gate.notify_all();
|
||||
}
|
||||
lock.unlock();
|
||||
return task.get();
|
||||
}
|
||||
static void playable(const HTTP &response, const std::string &path, bool video, double minimumTime, double maximumTime = INFINITY) {
|
||||
assert(response.status == 200 && response.bytes.size() > 188 && response.bytes[0] == 0x47); writeFile(path, response.bytes);
|
||||
AVFormatContext *format = nullptr; ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr)); ok(avformat_find_stream_info(format, nullptr));
|
||||
int audioIndex = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(audioIndex >= 0);
|
||||
int videoIndex = av_find_best_stream(format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); assert(!video || videoIndex >= 0);
|
||||
std::map<int, AVCodecContext *> codecs; std::map<int, int> frames;
|
||||
std::map<int, double> first;
|
||||
for (int index : {audioIndex, videoIndex}) if (index >= 0) {
|
||||
auto *stream = format->streams[index]; auto *codec = avcodec_alloc_context3(avcodec_find_decoder(stream->codecpar->codec_id)); assert(codec);
|
||||
ok(avcodec_parameters_to_context(codec, stream->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr)); codecs[index] = codec;
|
||||
}
|
||||
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc(); bool timestamp = false;
|
||||
while (av_read_frame(format, packet) >= 0) {
|
||||
auto found = codecs.find(packet->stream_index);
|
||||
if (found != codecs.end()) {
|
||||
if (packet->pts != AV_NOPTS_VALUE) {
|
||||
double time = packet->pts * av_q2d(format->streams[packet->stream_index]->time_base);
|
||||
assert(time >= minimumTime - .1 && time <= maximumTime + .1);
|
||||
if (!first.contains(packet->stream_index)) first[packet->stream_index] = time;
|
||||
timestamp = true;
|
||||
}
|
||||
ok(avcodec_send_packet(found->second, packet));
|
||||
while (avcodec_receive_frame(found->second, frame) >= 0) {
|
||||
if (packet->stream_index == videoIndex) {
|
||||
if (std::isfinite(maximumTime)) assert(frame->height == 64);
|
||||
auto sar = av_guess_sample_aspect_ratio(format, format->streams[videoIndex], frame);
|
||||
if (sar.num <= 0 || sar.den <= 0) sar = {1, 1};
|
||||
assert(sar.num > 0 && std::abs(double(frame->width) / frame->height * av_q2d(sar) - 2.0) < .01);
|
||||
}
|
||||
++frames[packet->stream_index]; av_frame_unref(frame);
|
||||
}
|
||||
}
|
||||
av_packet_unref(packet);
|
||||
}
|
||||
assert(timestamp && frames[audioIndex] > 10 && (!video || frames[videoIndex] > 10));
|
||||
if (std::isfinite(maximumTime)) for (int index : {audioIndex, videoIndex}) if (index >= 0)
|
||||
assert(first.contains(index) && std::abs(first[index] - minimumTime) < .1);
|
||||
for (auto &[index, codec] : codecs) avcodec_free_context(&codec);
|
||||
av_packet_free(&packet); av_frame_free(&frame); avformat_close_input(&format);
|
||||
}
|
||||
static std::pair<double, double> audioSpan(const HTTP &response, const std::string &path) {
|
||||
assert(response.status == 200);
|
||||
writeFile(path, response.bytes);
|
||||
AVFormatContext *format = nullptr;
|
||||
ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr));
|
||||
ok(avformat_find_stream_info(format, nullptr));
|
||||
int index = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(index >= 0);
|
||||
auto *codec = avcodec_alloc_context3(avcodec_find_decoder(format->streams[index]->codecpar->codec_id)); assert(codec);
|
||||
ok(avcodec_parameters_to_context(codec, format->streams[index]->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr));
|
||||
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc();
|
||||
double first = INFINITY, end = -INFINITY;
|
||||
while (av_read_frame(format, packet) >= 0) {
|
||||
if (packet->stream_index == index) {
|
||||
ok(avcodec_send_packet(codec, packet));
|
||||
while (avcodec_receive_frame(codec, frame) >= 0) {
|
||||
assert(frame->best_effort_timestamp != AV_NOPTS_VALUE);
|
||||
double position = frame->best_effort_timestamp * av_q2d(format->streams[index]->time_base);
|
||||
first = std::min(first, position);
|
||||
end = position + double(frame->nb_samples) / frame->sample_rate;
|
||||
av_frame_unref(frame);
|
||||
}
|
||||
}
|
||||
av_packet_unref(packet);
|
||||
}
|
||||
av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&codec); avformat_close_input(&format);
|
||||
assert(std::isfinite(first) && end > first);
|
||||
return {first, end};
|
||||
}
|
||||
static CastMedia *start(CastMediaSource source, std::string &url, double &duration) {
|
||||
char error[512]{}, bytes[512]{}; auto *media = cast_media_create(&source, error); assert(media);
|
||||
assert(cast_media_start(media, "127.0.0.1", bytes, &duration, error)); url = bytes; return media;
|
||||
}
|
||||
static void seekWork(Upstream &upstream, const std::string &segment) {
|
||||
std::string video = upstream.url("/video.mp4"), audio = upstream.url("/audio.m4a"), url;
|
||||
double duration = 0;
|
||||
CastMediaSource source{video.c_str(), "X-Video: video-secret\r\n", audio.c_str(), "X-Audio: audio-secret\r\n", false, false, false, 90};
|
||||
auto *media = start(source, url, duration);
|
||||
std::string base = url.substr(0, url.rfind('/') + 1);
|
||||
int videoStarts = upstream.videoStarts, audioStarts = upstream.audioStarts;
|
||||
auto distant = request(base + "segment/10.ts");
|
||||
playable(distant, segment, true, 60);
|
||||
int videoRequests = upstream.videoRequests, audioRequests = upstream.audioRequests;
|
||||
auto repeated = request(base + "segment/10.ts");
|
||||
std::cout << "seek-work repeated upstream requests: video=" << int(upstream.videoRequests) - videoRequests
|
||||
<< " audio=" << int(upstream.audioRequests) - audioRequests << std::endl;
|
||||
bool repeatedHasNoIO = upstream.videoRequests == videoRequests && upstream.audioRequests == audioRequests;
|
||||
bool repeatedIdentical = repeated.status == 200 && repeated.bytes == distant.bytes;
|
||||
auto adjacent = request(base + "segment/11.ts");
|
||||
playable(adjacent, segment, true, 66);
|
||||
auto before = audioSpan(distant, segment), after = audioSpan(adjacent, segment);
|
||||
assert(std::abs(after.first - before.second) < 1.0 / 48000);
|
||||
playable(request(base + "segment/2.ts"), segment, true, 12);
|
||||
playable(request(base + "segment/12.ts"), segment, true, 72);
|
||||
std::cout << "seek-work new origin-zero requests across distant/adjacent seeks: video="
|
||||
<< int(upstream.videoStarts) - videoStarts << " audio=" << int(upstream.audioStarts) - audioStarts << std::endl;
|
||||
bool seeksDoNotReprobe = upstream.videoStarts == videoStarts && upstream.audioStarts == audioStarts;
|
||||
// A previous random segment remains independently replayable without upstream IO.
|
||||
videoRequests = upstream.videoRequests; audioRequests = upstream.audioRequests;
|
||||
bool replayIdentical = request(base + "segment/10.ts").bytes == distant.bytes;
|
||||
auto head = request(base + "segment/10.ts", "HEAD");
|
||||
auto range = request(base + "segment/10.ts", "GET", "Range: bytes=0-187\r\n");
|
||||
assert(head.status == 200 && head.bytes.empty());
|
||||
assert(range.status == 206 && range.bytes == std::vector<uint8_t>(distant.bytes.begin(), distant.bytes.begin() + 188));
|
||||
bool replayHasNoIO = upstream.videoRequests == videoRequests && upstream.audioRequests == audioRequests;
|
||||
cast_media_destroy(media);
|
||||
assert(seeksDoNotReprobe);
|
||||
assert(repeatedHasNoIO && repeatedIdentical && replayHasNoIO && replayIdentical);
|
||||
}
|
||||
static int mediaCount(const std::map<std::string, int> &counts, const std::string &prefix) {
|
||||
int total = 0;
|
||||
for (const auto &[path, count] : counts) if (path.starts_with(prefix)) total += count;
|
||||
return total;
|
||||
}
|
||||
static void retainedMedia(const char *stage, const std::map<std::string, int> &before, const std::map<std::string, int> &after) {
|
||||
for (const auto &[path, count] : after) {
|
||||
int previous = before.contains(path) ? before.at(path) : 0;
|
||||
int allowed = path.starts_with("/hls/unused-") ? previous : std::max(previous, 1);
|
||||
if (count > allowed)
|
||||
std::cerr << "hls-work retention failure: stage=" << stage << " media=" << path
|
||||
<< " previous=" << previous << " actual=" << count << " allowed=" << allowed << std::endl;
|
||||
if (path.starts_with("/hls/unused-")) assert(count == previous);
|
||||
else assert(count <= std::max(previous, 1));
|
||||
}
|
||||
}
|
||||
static void hlsWork(Upstream &upstream, const std::string &segment) {
|
||||
std::string hls = upstream.url("/hls/master.m3u8"), url;
|
||||
double duration = 0;
|
||||
CastMediaSource source{hls.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 180};
|
||||
auto *media = throughGate(upstream, [&] { return start(source, url, duration); });
|
||||
assert(duration >= 179 && duration <= 182);
|
||||
const auto prepared = upstream.counts();
|
||||
assert(mediaCount(prepared, "/hls/selected-") > 0 && upstream.foreignRequests == 0);
|
||||
const std::string base = url.substr(0, url.rfind('/') + 1);
|
||||
auto fetch = [&](int id) {
|
||||
const auto before = upstream.counts();
|
||||
std::cout << "hls-work requesting segment=" << id << " position=" << id * 6 << std::endl;
|
||||
auto response = throughGate(upstream, [&] { return request(base + "segment/" + std::to_string(id) + ".ts"); });
|
||||
for (const auto &[path, count] : upstream.counts()) {
|
||||
int previous = before.contains(path) ? before.at(path) : 0;
|
||||
if (count != previous)
|
||||
std::cout << "hls-work segment=" << id << " media=" << path
|
||||
<< " previous=" << previous << " actual=" << count << std::endl;
|
||||
}
|
||||
// The relay's shared positive MPEG-TS clock origin is one second; media positions remain id * 6.
|
||||
playable(response, segment, true, id * 6 + 1, (id + 1) * 6 + 1);
|
||||
return response;
|
||||
};
|
||||
auto first = fetch(2), adjacent = fetch(3);
|
||||
auto firstAudio = audioSpan(first, segment), adjacentAudio = audioSpan(adjacent, segment);
|
||||
assert(std::abs(adjacentAudio.first - firstAudio.second) < 1.0 / 48000);
|
||||
fetch(1); // New output segment, but both A/V lanes must rewind retained source packets.
|
||||
assert(fetch(2).bytes == first.bytes);
|
||||
const auto early = upstream.counts();
|
||||
std::cout << "hls-work media GETs after preparation: selected="
|
||||
<< mediaCount(early, "/hls/selected-") - mediaCount(prepared, "/hls/selected-")
|
||||
<< " unused=" << mediaCount(early, "/hls/unused-") - mediaCount(prepared, "/hls/unused-") << std::endl;
|
||||
retainedMedia("prepared -> early 2,3,1,2", prepared, early);
|
||||
|
||||
// Beyond the 60-second read-ahead window: this random seek requires real upstream IO.
|
||||
auto distant = fetch(24);
|
||||
const auto distantCounts = upstream.counts();
|
||||
assert(mediaCount(distantCounts, "/hls/selected-") > mediaCount(early, "/hls/selected-"));
|
||||
retainedMedia("early -> random 24", early, distantCounts);
|
||||
auto distantAdjacent = fetch(26);
|
||||
auto nearby = fetch(25); // Unrendered nearby output rewinds already-read source, not speculative history before a miss.
|
||||
auto nearbyAudio = audioSpan(nearby, segment), distantAdjacentAudio = audioSpan(distantAdjacent, segment);
|
||||
assert(std::abs(distantAdjacentAudio.first - nearbyAudio.second) < 1.0 / 48000);
|
||||
assert(fetch(24).bytes == distant.bytes);
|
||||
const auto nearbyCounts = upstream.counts();
|
||||
retainedMedia("random 24 -> 26,25,24", distantCounts, nearbyCounts);
|
||||
|
||||
// Behind the 30-second retained window: a backward random miss must still decode at the target.
|
||||
auto backward = fetch(5);
|
||||
const auto backwardCounts = upstream.counts();
|
||||
assert(mediaCount(backwardCounts, "/hls/selected-") > mediaCount(nearbyCounts, "/hls/selected-"));
|
||||
assert(mediaCount(backwardCounts, "/hls/unused-") == mediaCount(prepared, "/hls/unused-"));
|
||||
assert(fetch(5).bytes == backward.bytes);
|
||||
retainedMedia("backward 5 -> replay 5", backwardCounts, upstream.counts());
|
||||
|
||||
// Do not release the next media request. Cancel/destroy must finish while upstream is held.
|
||||
std::promise<void> sent;
|
||||
auto sentRequest = sent.get_future();
|
||||
auto blocked = std::async(std::launch::async, [&] { return request(base + "segment/20.ts", "GET", "", true, &sent); });
|
||||
assert(sentRequest.wait_for(std::chrono::seconds(10)) == std::future_status::ready);
|
||||
int released = 0;
|
||||
{
|
||||
std::unique_lock lock(upstream.mutex);
|
||||
assert(upstream.gate.wait_for(lock, std::chrono::seconds(10), [&] { return upstream.held > upstream.released; }));
|
||||
released = upstream.released;
|
||||
}
|
||||
assert(blocked.wait_for(std::chrono::seconds(0)) == std::future_status::timeout);
|
||||
auto destroyed = std::async(std::launch::async, [&] { cast_media_cancel(media); cast_media_destroy(media); });
|
||||
assert(destroyed.wait_for(std::chrono::seconds(3)) == std::future_status::ready);
|
||||
destroyed.get();
|
||||
assert(blocked.wait_for(std::chrono::seconds(3)) == std::future_status::ready);
|
||||
auto cancelled = blocked.get(); assert(cancelled.status == 0 || cancelled.status == 503);
|
||||
{
|
||||
std::lock_guard lock(upstream.mutex);
|
||||
assert(upstream.released == released && upstream.held > upstream.released);
|
||||
upstream.released = upstream.held;
|
||||
upstream.gate.notify_all();
|
||||
}
|
||||
assert(upstream.foreignRequests == 0);
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
bool liveCheck = false, seekCheck = false, hlsCheck = false;
|
||||
for (int argument = 1; argument < argc; ++argument) {
|
||||
if (std::string(argv[argument]) == "--live") liveCheck = true;
|
||||
if (std::string(argv[argument]) == "--seek-work") seekCheck = true;
|
||||
if (std::string(argv[argument]) == "--hls-work") hlsCheck = true;
|
||||
}
|
||||
char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp);
|
||||
const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string();
|
||||
fixture(video); writeFile(audio, wav());
|
||||
const auto splitVideo = (directory / "video.mp4").string(), splitAudio = (directory / "audio.m4a").string();
|
||||
splitFixture(video, splitVideo, AVMEDIA_TYPE_VIDEO);
|
||||
splitFixture(video, splitAudio, AVMEDIA_TYPE_AUDIO);
|
||||
auto videoBytes = readFile(video), audioBytes = readFile(audio);
|
||||
Upstream upstream(videoBytes, audioBytes), foreign(videoBytes, audioBytes);
|
||||
upstream.resources["/video.mp4"] = readFile(splitVideo);
|
||||
upstream.resources["/audio.m4a"] = readFile(splitAudio);
|
||||
upstream.foreign = foreign.url(""); upstream.start(); foreign.start();
|
||||
seekWork(upstream, segment);
|
||||
if (seekCheck) {
|
||||
std::filesystem::remove_all(directory);
|
||||
std::cout << "cast-media: split VOD seek work passed\n";
|
||||
return 0;
|
||||
}
|
||||
const auto selectedHLS = (directory / "selected.ts").string(), unusedHLS = (directory / "unused.ts").string();
|
||||
fixture(selectedHLS, 180);
|
||||
fixture(unusedHLS, 180, 48, 32, 100000);
|
||||
Upstream delayed(videoBytes, audioBytes);
|
||||
delayed.resources.merge(hlsFixture(selectedHLS, unusedHLS, directory / "hls"));
|
||||
delayed.gated = true; delayed.start();
|
||||
hlsWork(delayed, segment);
|
||||
if (hlsCheck) {
|
||||
std::filesystem::remove_all(directory);
|
||||
std::cout << "cast-media: authenticated delayed multi-variant combined HLS passed\n";
|
||||
return 0;
|
||||
}
|
||||
std::string url; double duration = 0;
|
||||
CastMediaSource local{video.c_str(), "", nullptr, nullptr, true, false, false, 0};
|
||||
auto *media = start(local, url, duration); assert(duration >= 89 && duration <= 92);
|
||||
std::string base = url.substr(0, url.rfind('/') + 1);
|
||||
auto playlist = request(url); assert(playlist.status == 200); std::string text(playlist.bytes.begin(), playlist.bytes.end()); assert(text.find("#EXT-X-ENDLIST") != std::string::npos);
|
||||
playable(request(base + "segment/10.ts"), segment, true, 60);
|
||||
playable(request(base + "segment/0.ts"), segment, true, 0);
|
||||
assert(request(base + "../source.ts").status == 404);
|
||||
assert(request(base + "%2e%2e/source.ts").status == 404);
|
||||
assert(request(url.substr(0, url.find('/', 7)) + "/index.m3u8").status == 404);
|
||||
assert(request(base + "segment/999999.ts").status == 404);
|
||||
const char vtt[] = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n"; char subtitle[512]{};
|
||||
assert(cast_media_subtitle(media, 1, vtt, sizeof(vtt) - 1, subtitle));
|
||||
auto subtitles = request(subtitle); assert(subtitles.status == 200 && subtitles.headers.find("Access-Control-Allow-Origin: *") != std::string::npos);
|
||||
auto ranged = request(subtitle, "GET", "Range: bytes=0-5\r\n"); assert(ranged.status == 206 && ranged.bytes.size() == 6);
|
||||
assert(request(subtitle, "HEAD").bytes.empty()); assert(request(subtitle, "OPTIONS").status == 204);
|
||||
assert(request(subtitle, "GET", "Range: bytes=99999-\r\n").status == 416);
|
||||
cast_media_cancel(media); cast_media_destroy(media);
|
||||
// Previously published capabilities expire, including the bound listening socket.
|
||||
auto authority = url.substr(7, url.find('/', 7) - 7); auto colon = authority.find(':'); sockaddr_in closed{};
|
||||
closed.sin_family = AF_INET; closed.sin_addr.s_addr = htonl(INADDR_LOOPBACK); closed.sin_port = htons(std::stoi(authority.substr(colon + 1)));
|
||||
int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(connect(socket, reinterpret_cast<sockaddr *>(&closed), sizeof(closed)) != 0); close(socket);
|
||||
std::string hls = upstream.url("/index.m3u8"); CastMediaSource miruro{hls.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90};
|
||||
media = start(miruro, url, duration); base = url.substr(0, url.rfind('/') + 1);
|
||||
playable(request(base + "segment/10.ts"), segment, true, 60); cast_media_destroy(media);
|
||||
std::string networkVideo = upstream.url("/source.ts"), networkAudio = upstream.url("/audio.wav");
|
||||
CastMediaSource youtube{networkVideo.c_str(), "X-Video: video-secret\r\n", networkAudio.c_str(), "X-Audio: audio-secret\r\n", false, false, false, 90};
|
||||
media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1);
|
||||
playable(request(base + "segment/10.ts"), segment, true, 60);
|
||||
auto vodFirstAudio = audioSpan(request(base + "segment/0.ts"), segment);
|
||||
auto vodSecondAudio = audioSpan(request(base + "segment/1.ts"), segment);
|
||||
assert(std::abs(vodSecondAudio.first - vodFirstAudio.second) < 1.0 / 48000);
|
||||
cast_media_destroy(media);
|
||||
assert(upstream.videoRequests > 0 && upstream.audioRequests > 0 && upstream.foreignRequests == 0);
|
||||
CastMediaSource audioOnly{audio.c_str(), "", nullptr, nullptr, true, false, false, 0};
|
||||
media = start(audioOnly, url, duration); base = url.substr(0, url.rfind('/') + 1); playable(request(base + "segment/10.ts"), segment, false, 60); cast_media_destroy(media);
|
||||
for (const auto &path : {"/redirect", "/cross.m3u8"}) {
|
||||
auto resource = upstream.url(path); CastMediaSource unsafe{resource.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90};
|
||||
char error[512]{}, result[512]{}; media = cast_media_create(&unsafe, error); assert(media);
|
||||
assert(!cast_media_start(media, "127.0.0.1", result, &duration, error)); assert(std::string(error).find("secret") == std::string::npos); cast_media_destroy(media);
|
||||
}
|
||||
assert(foreign.foreignRequests == 0 && foreign.videoRequests == 0);
|
||||
auto publicManifest = upstream.url("/public-cross.m3u8");
|
||||
CastMediaSource publicHLS{publicManifest.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, false, false, 90};
|
||||
media = start(publicHLS, url, duration); base = url.substr(0, url.rfind('/') + 1);
|
||||
playable(request(base + "segment/0.ts"), segment, true, 0); cast_media_destroy(media);
|
||||
assert(foreign.videoRequests > 0 && foreign.foreignRequests == 0);
|
||||
if (liveCheck) {
|
||||
youtube.live = true; media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1); assert(duration == 0);
|
||||
auto firstAudio = audioSpan(request(base + "segment/0.ts"), segment);
|
||||
auto secondAudio = audioSpan(request(base + "segment/1.ts"), segment);
|
||||
assert(std::abs(secondAudio.first - firstAudio.second) < 1.0 / 48000);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
assert(cast_media_check_advance(media, 54));
|
||||
#else
|
||||
assert(false && "Compile the permanent live check with CAST_MEDIA_CHECK");
|
||||
#endif
|
||||
auto response = request(url); assert(response.status == 200); std::string text(response.bytes.begin(), response.bytes.end());
|
||||
auto at = text.find("#EXT-X-MEDIA-SEQUENCE:"); assert(at != std::string::npos);
|
||||
int64_t sequence = std::stoll(text.substr(at + 22));
|
||||
size_t count = 0, cursor = 0;
|
||||
while ((cursor = text.find("#EXTINF:", cursor)) != std::string::npos) { ++count; cursor += 8; }
|
||||
assert(count == 8 && text.find("#EXT-X-ENDLIST") == std::string::npos);
|
||||
int64_t last = sequence + int64_t(count) - 1;
|
||||
assert(sequence > 0 && request(base + "segment/0.ts").status == 404);
|
||||
playable(request(base + "segment/" + std::to_string(last) + ".ts"), segment, true, 0); cast_media_destroy(media);
|
||||
}
|
||||
std::filesystem::remove_all(directory);
|
||||
std::cout << "cast-media: playable A/V, VOD random seek, separate audio, authenticated delayed multi-variant HLS retention, HLS origin/header policy, subtitles, ranges, capabilities, gated cancellation" << (liveCheck ? ", bounded live window" : " (use --live for rolling window)") << " passed\n";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct CastPlaybackCheck {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
|
||||
// A real I/O watchdog, not a clock used by the playback behavior under test.
|
||||
static func until(_ model: PlayerModel, _ message: String, permitError: Bool = false, _ predicate: () -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if !permitError, let error = model.commandError { fatalError("FAIL: " + error) }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle")
|
||||
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" {
|
||||
print("PASS: native volume slider tracking preserves user input and idle receiver feedback")
|
||||
return
|
||||
}
|
||||
let source = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let port = UInt16(CommandLine.arguments[2])!
|
||||
let suite = "terebi.cast-playback-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
defer { model.shutdown() }
|
||||
model.setVolume(0)
|
||||
model.open(source)
|
||||
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
require(model.setPlayback(playing: false), "local pause accepted")
|
||||
try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
|
||||
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
|
||||
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
|
||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||
try await until(model, "receiver load acknowledges paused handoff") {
|
||||
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
|
||||
}
|
||||
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
|
||||
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
|
||||
require(model.setPlayback(playing: true), "remote play accepted")
|
||||
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||
require(model.seekRelative(5) && model.seekRelative(10), "rapid remote skips accumulate before acknowledgement")
|
||||
try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 }
|
||||
require(model.setPlayback(playing: false), "remote pause accepted")
|
||||
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
model.stopCasting()
|
||||
try await until(model, "local decoder restores receiver position paused") {
|
||||
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
|
||||
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
|
||||
}
|
||||
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
|
||||
"remote history is not overwritten by the abandoned local position")
|
||||
require(model.volume == 0, "local volume survives receiver volume changes")
|
||||
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
|
||||
try await until(model, "local resume acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||
model.cast(to: CastDevice(id: "invalid", name: "Unavailable TV", host: "127.0.0.1", port: 0))
|
||||
try await until(model, "failed Cast connection restores local playback", permitError: true) {
|
||||
model.castDevice == nil && model.commandError != nil && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
require(model.canSeek && model.state.position >= 24, "connection failure preserves a usable local timeline")
|
||||
model.closeFile()
|
||||
try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY }
|
||||
print("PASS: real local decode → TLS Cast LOAD/play/seek/pause → local restoration/history; failed connection recovery")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
import Foundation
|
||||
@preconcurrency import Network
|
||||
import Security
|
||||
|
||||
#if CAST_SESSION_CHECKS
|
||||
struct CastTextTrack: Sendable {
|
||||
let id: Int
|
||||
let url: URL
|
||||
let language: String
|
||||
let label: String
|
||||
}
|
||||
struct CastMediaDescription: Sendable {
|
||||
let url: URL
|
||||
let contentType: String
|
||||
let duration: Double
|
||||
let live: Bool
|
||||
let tracks: [CastTextTrack]
|
||||
}
|
||||
#endif
|
||||
|
||||
private func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func until(_ message: String, attempts: Int = 500, _ predicate: () -> Bool) async throws {
|
||||
// An I/O watchdog, not a wall-clock date used to decide playback behavior.
|
||||
for _ in 0..<attempts {
|
||||
if predicate() { return }
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
@MainActor
|
||||
private final class CastCheckClock {
|
||||
private struct Waiter {
|
||||
let delay: Duration
|
||||
let deadline: Duration
|
||||
let continuation: CheckedContinuation<Void, any Error>
|
||||
}
|
||||
private var now: Duration = .zero
|
||||
private var waiters: [UUID: Waiter] = [:]
|
||||
var pendingCount: Int { waiters.count }
|
||||
func hasDelay(_ delay: Duration) -> Bool { waiters.values.contains { $0.delay == delay } }
|
||||
|
||||
func sleep(_ delay: Duration) async throws {
|
||||
try Task.checkCancellation()
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
waiters[id] = Waiter(delay: delay, deadline: now + delay, continuation: continuation)
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.waiters.removeValue(forKey: id)?.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
|
||||
func advance(_ duration: Duration) {
|
||||
now += duration
|
||||
let due = waiters.filter { $0.value.deadline <= now }.map(\.key)
|
||||
for id in due { waiters.removeValue(forKey: id)?.continuation.resume() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private final class Receiver {
|
||||
private let listener: NWListener
|
||||
private var socket: NWConnection?
|
||||
private var stream = CastWire.Stream()
|
||||
private var sender = ""
|
||||
private let transport = "receiver-app"
|
||||
private var media: [String: Any] = [:]
|
||||
private var session = 1
|
||||
private var position = 0.0
|
||||
private var playing = false
|
||||
private var activeTracks: [Int] = []
|
||||
var commands: [(String, [String: Any])] = []
|
||||
var rejectLoad = false
|
||||
var rejectLaunch = false
|
||||
var dropLaunch = false
|
||||
var dropLoad = false
|
||||
var dropCommands = false
|
||||
var rejectCommand = false
|
||||
var holdSeek = false
|
||||
var holdVolume = false
|
||||
var ignoreHeartbeat = false
|
||||
var verifyRelay = false
|
||||
var relayVerified = false
|
||||
var failure: String?
|
||||
private var heldSeek: [String: Any]?
|
||||
private var started = false
|
||||
private var volume = 1.0
|
||||
var muted = false
|
||||
var port: UInt16 { listener.port!.rawValue }
|
||||
var device: CastDevice { .init(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port) }
|
||||
|
||||
init(identity: SecIdentity) throws {
|
||||
let tls = NWProtocolTLS.Options()
|
||||
guard let identity = sec_identity_create(identity) else { throw CastError.protocolError }
|
||||
sec_protocol_options_set_local_identity(tls.securityProtocolOptions, identity)
|
||||
sec_protocol_options_set_min_tls_protocol_version(tls.securityProtocolOptions, .TLSv12)
|
||||
let parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
|
||||
listener = try NWListener(using: parameters)
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
switch state {
|
||||
case .ready: self?.started = true
|
||||
case .failed: self?.failure = "TLS listener failed"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] socket in
|
||||
Task { @MainActor in
|
||||
guard let self else { socket.cancel(); return }
|
||||
self.socket?.cancel()
|
||||
self.socket = socket
|
||||
self.stream = CastWire.Stream()
|
||||
socket.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
if case .ready = state { self?.receive(socket) }
|
||||
}
|
||||
}
|
||||
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
listener.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
try await until("TLS listener starts") { started || failure != nil }
|
||||
require(failure == nil, "TLS listener succeeds")
|
||||
}
|
||||
|
||||
func stop() { socket?.cancel(); listener.cancel() }
|
||||
|
||||
private func receive(_ socket: NWConnection) {
|
||||
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
do {
|
||||
if let data {
|
||||
for message in try self.stream.append(data) { try await self.handle(message) }
|
||||
}
|
||||
if !ended, error == nil { self.receive(socket) }
|
||||
} catch { self.failure = "Simulated receiver protocol/relay failure: \(error.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ json: [String: Any], namespace: String, source: String? = nil, fragmented: Bool = false) throws {
|
||||
let payload = try JSONSerialization.data(withJSONObject: json)
|
||||
let frame = try CastWire.frame(.init(source: source ?? (namespace == CastWire.media ? transport : "receiver-0"),
|
||||
destination: sender, namespace: namespace, payload: payload))
|
||||
if fragmented {
|
||||
// Split both the length prefix and protobuf, exercising genuine receive framing.
|
||||
for chunk in [frame.prefix(2), frame.dropFirst(2).prefix(5), frame.dropFirst(7)] {
|
||||
socket?.send(content: Data(chunk), completion: .contentProcessed { _ in })
|
||||
}
|
||||
} else { socket?.send(content: frame, completion: .contentProcessed { _ in }) }
|
||||
}
|
||||
|
||||
func receiverStatus(_ requestID: Int? = nil, appID: String? = "CC1AD845",
|
||||
level: Double? = nil, source: String? = nil) throws {
|
||||
let apps: [[String: Any]] = appID.map { [["appId": $0, "sessionId": "app-session", "transportId": transport]] } ?? []
|
||||
var json: [String: Any] = ["type": "RECEIVER_STATUS",
|
||||
"status": ["applications": apps, "volume": ["level": level ?? volume, "muted": muted]]]
|
||||
if let requestID { json["requestId"] = requestID }
|
||||
try send(json, namespace: CastWire.receiver, source: source, fragmented: true)
|
||||
}
|
||||
|
||||
func receiverPing() throws {
|
||||
try send(["type": "PING"], namespace: CastWire.heartbeat)
|
||||
}
|
||||
|
||||
func status(requestID: Int = 0, session override: Int? = nil, content: String? = nil,
|
||||
state: String? = nil, idle: String? = nil) throws {
|
||||
var media = self.media
|
||||
if let content { media["contentId"] = content }
|
||||
try send(["type": "MEDIA_STATUS", "requestId": requestID,
|
||||
"status": [["mediaSessionId": override ?? session, "media": media,
|
||||
"playerState": state ?? (playing ? "PLAYING" : "PAUSED"), "currentTime": position,
|
||||
"idleReason": idle ?? "", "supportedMediaCommands": 4099, "activeTrackIds": activeTracks]]],
|
||||
namespace: CastWire.media, fragmented: true)
|
||||
}
|
||||
|
||||
func releaseSeek() throws {
|
||||
guard let command = heldSeek else { return }
|
||||
heldSeek = nil
|
||||
position = command["currentTime"] as? Double ?? position
|
||||
try status(requestID: command["requestId"] as? Int ?? 0)
|
||||
}
|
||||
|
||||
func invalidFrame() {
|
||||
socket?.send(content: Data([0, 1, 0, 1]), completion: .contentProcessed { _ in }) // 65537 > bound
|
||||
}
|
||||
|
||||
func takeOverApplication() throws {
|
||||
try send(["type": "RECEIVER_STATUS", "status": ["applications": []]], namespace: CastWire.receiver)
|
||||
}
|
||||
|
||||
private func handle(_ message: CastWire.Message) async throws {
|
||||
sender = message.source
|
||||
let json = try JSONSerialization.jsonObject(with: message.payload) as! [String: Any]
|
||||
let type = json["type"] as! String
|
||||
let requestID = json["requestId"] as? Int ?? 0
|
||||
commands.append((type, json))
|
||||
if message.namespace == CastWire.heartbeat {
|
||||
if type == "PING", !ignoreHeartbeat { try send(["type": "PONG"], namespace: CastWire.heartbeat) }
|
||||
return
|
||||
}
|
||||
switch type {
|
||||
case "CONNECT": break
|
||||
case "CLOSE": break
|
||||
case "LAUNCH":
|
||||
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
|
||||
if dropLaunch { return }
|
||||
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
|
||||
else { try receiverStatus(requestID) }
|
||||
case "LOAD":
|
||||
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
|
||||
if dropLoad { return }
|
||||
media = json["media"] as! [String: Any]
|
||||
session += 1
|
||||
playing = json["autoplay"] as? Bool ?? true
|
||||
position = json["currentTime"] as? Double ?? 0
|
||||
activeTracks = json["activeTrackIds"] as? [Int] ?? []
|
||||
if verifyRelay { try await fetchRelay() }
|
||||
try status(requestID: requestID)
|
||||
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
|
||||
if dropCommands { return }
|
||||
if rejectCommand {
|
||||
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
|
||||
return
|
||||
}
|
||||
require(json["mediaSessionId"] as? Int == session, "Controls address the acquired media session")
|
||||
if type == "PLAY" { playing = true }
|
||||
if type == "PAUSE" { playing = false }
|
||||
if type == "EDIT_TRACKS_INFO" { activeTracks = json["activeTrackIds"] as? [Int] ?? [] }
|
||||
if type == "SEEK" {
|
||||
if holdSeek { heldSeek = json; try status(); return }
|
||||
position = json["currentTime"] as? Double ?? position
|
||||
}
|
||||
try status(requestID: requestID)
|
||||
case "SET_VOLUME":
|
||||
require(message.namespace == CastWire.receiver, "Volume controls device, not stream")
|
||||
if rejectCommand {
|
||||
try send(["type": "INVALID_REQUEST", "requestId": requestID], namespace: CastWire.receiver)
|
||||
return
|
||||
}
|
||||
volume = (json["volume"] as? [String: Any])?["level"] as? Double ?? volume
|
||||
muted = (json["volume"] as? [String: Any])?["muted"] as? Bool ?? muted
|
||||
if !holdVolume { try receiverStatus(requestID) }
|
||||
case "STOP":
|
||||
require(message.namespace == CastWire.media, "Disconnect does not stop another sender's application")
|
||||
require(json["mediaSessionId"] as? Int == session, "Disconnect stops only owned media")
|
||||
default: failure = "Unexpected command \(type)"
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchRelay() async throws {
|
||||
let url = URL(string: media["contentId"] as! String)!
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 30
|
||||
let session = URLSession(configuration: config)
|
||||
defer { session.invalidateAndCancel() }
|
||||
func fetch(_ url: URL) async throws -> Data {
|
||||
let (data, response) = try await session.data(from: url)
|
||||
require((response as? HTTPURLResponse)?.statusCode == 200, "Receiver fetch succeeds")
|
||||
require(!data.isEmpty, "Relay returns real bytes")
|
||||
return data
|
||||
}
|
||||
var playlistURL = url
|
||||
var playlist = try await fetch(playlistURL)
|
||||
var body = String(data: playlist, encoding: .utf8) ?? ""
|
||||
require(body.hasPrefix("#EXTM3U"), "LOAD is playable HLS")
|
||||
for _ in 0..<3 {
|
||||
guard let uri = body.split(separator: "\n").first(where: { !$0.hasPrefix("#") && !$0.isEmpty }),
|
||||
let next = URL(string: String(uri).trimmingCharacters(in: .whitespacesAndNewlines), relativeTo: playlistURL)?.absoluteURL else {
|
||||
fatalError("FAIL: playlist has a media resource")
|
||||
}
|
||||
let data = try await fetch(next)
|
||||
if let text = String(data: data, encoding: .utf8), text.hasPrefix("#EXTM3U") {
|
||||
playlistURL = next; playlist = data; body = text; continue
|
||||
}
|
||||
require(data.count >= 188 && data.first == 0x47 || data.count >= 12 &&
|
||||
["ftyp", "styp", "moof"].contains(String(data: data[4..<8], encoding: .ascii) ?? ""),
|
||||
"Relay serves MPEG-TS or fragmented MP4, not placeholder content")
|
||||
for track in media["tracks"] as? [[String: Any]] ?? [] {
|
||||
if let textURL = (track["trackContentId"] as? String).flatMap(URL.init(string:)) {
|
||||
let text = try await fetch(textURL)
|
||||
require(String(data: text, encoding: .utf8)?.hasPrefix("WEBVTT") == true, "Receiver subtitles are WebVTT")
|
||||
}
|
||||
}
|
||||
relayVerified = true
|
||||
return
|
||||
}
|
||||
fatalError("FAIL: receiver playlist resolves to real media")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func testIdentity() throws -> (SecIdentity, URL) {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-check-" + UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
func openssl(_ arguments: [String]) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/openssl")
|
||||
process.arguments = arguments
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
require(process.terminationStatus == 0, "Creates ephemeral self-signed TLS test identity")
|
||||
}
|
||||
let key = directory.appendingPathComponent("key.pem").path
|
||||
let certificate = directory.appendingPathComponent("cert.pem").path
|
||||
let bundle = directory.appendingPathComponent("identity.p12")
|
||||
try openssl(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key,
|
||||
"-out", certificate, "-days", "1", "-subj", "/CN=Cast loopback"])
|
||||
try openssl(["pkcs12", "-export", "-inkey", key, "-in", certificate, "-out", bundle.path,
|
||||
"-passout", "pass:cast-check", "-keypbe", "PBE-SHA1-3DES", "-certpbe", "PBE-SHA1-3DES", "-macalg", "sha1"])
|
||||
let data = try Data(contentsOf: bundle)
|
||||
var result: CFArray?
|
||||
let status = SecPKCS12Import(data as CFData,
|
||||
[kSecImportExportPassphrase as String: "cast-check"] as CFDictionary, &result)
|
||||
require(status == errSecSuccess, "Imports ephemeral TLS identity")
|
||||
let item = (result! as! [[String: Any]])[0]
|
||||
return (item[kSecImportItemIdentity as String] as! SecIdentity, directory)
|
||||
}
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct CastSessionCheck {
|
||||
static func codec() throws {
|
||||
let payload = Data("{\"type\":\"PING\"}".utf8)
|
||||
let message = CastWire.Message(source: "sender-0", destination: "receiver-0",
|
||||
namespace: CastWire.heartbeat, payload: payload)
|
||||
let frame = try CastWire.frame(message)
|
||||
var stream = CastWire.Stream()
|
||||
var decoded: [CastWire.Message] = []
|
||||
for byte in frame { decoded += try stream.append(Data([byte])) }
|
||||
require(decoded.count == 1 && decoded[0].payload == payload, "Byte-fragmented frame round trips")
|
||||
let pair = try stream.append(frame + frame)
|
||||
require(pair.count == 2, "Coalesced frames decode")
|
||||
for malformed in [Data([0, 0, 0, 0]), Data([0, 1, 0, 1]), Data([255, 255, 255, 255])] {
|
||||
do { var parser = CastWire.Stream(); _ = try parser.append(malformed); fatalError("FAIL: invalid length accepted") }
|
||||
catch CastError.protocolError { }
|
||||
}
|
||||
for malformed in [Data([8, 128]), Data([8, 0, 18, 127]), Data([15]), Data(repeating: 255, count: 12)] {
|
||||
do { _ = try CastWire.decode(malformed); fatalError("FAIL: malformed protobuf accepted") }
|
||||
catch CastError.protocolError { }
|
||||
}
|
||||
do {
|
||||
_ = try CastWire.frame(.init(source: "s", destination: "r", namespace: "m",
|
||||
payload: Data(repeating: 65, count: CastWire.maximumFrame)))
|
||||
fatalError("FAIL: oversized outbound frame accepted")
|
||||
} catch CastError.protocolError { }
|
||||
}
|
||||
|
||||
static func media(_ suffix: String = "first") -> CastMediaDescription {
|
||||
.init(url: URL(string: "http://127.0.0.1:9123/session-token/\(suffix).m3u8")!,
|
||||
contentType: "application/x-mpegURL", duration: 120, live: false,
|
||||
tracks: [.init(id: 17, url: URL(string: "http://127.0.0.1:9123/session-token/subtitle.vtt")!,
|
||||
language: "en", label: "English")])
|
||||
}
|
||||
|
||||
static func behavior(identity: SecIdentity) async throws {
|
||||
let peer = try Receiver(identity: identity)
|
||||
peer.muted = true
|
||||
try await peer.start()
|
||||
defer { peer.stop() }
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(sleep: { try await clock.sleep($0) })
|
||||
var statuses: [CastStatus] = []
|
||||
var errors: [String] = []
|
||||
var disconnected = false
|
||||
session.onStatus = { statuses.append($0) }
|
||||
session.onError = { errors.append($0) }
|
||||
session.onDisconnect = { _ in disconnected = true }
|
||||
try await session.connect(to: peer.device)
|
||||
require(peer.commands.first?.0 == "CONNECT", "TLS platform channel connects before launching")
|
||||
require(!session.setPlaying(true), "No media control before load acknowledgement")
|
||||
try await session.load(media(), title: "Check", position: 9.25, playing: false, subtitle: 0)
|
||||
require(statuses.last?.position == 9.25 && statuses.last?.playing == false && statuses.last?.subtitle == 0,
|
||||
"LOAD is receiver-authoritative and subtitle IDs map to indices")
|
||||
require(statuses.last?.volume == 0, "Pre-existing device mute is reflected in controls")
|
||||
require(session.setPlaying(true) && session.setPlaying(false), "Rapid opposite commands accepted")
|
||||
try await until("PLAY and PAUSE acknowledged in order") {
|
||||
peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.count == 2 && statuses.last?.playing == false
|
||||
}
|
||||
require(peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.map(\.0) == ["PLAY", "PAUSE"], "Ordered controls")
|
||||
let beforeBurst = statuses.count
|
||||
require(session.setPlaying(true) && session.setPlaying(false) && session.setPlaying(true),
|
||||
"Three rapid intents are accepted")
|
||||
try await until("Latest playback intent is acknowledged") {
|
||||
statuses.last?.playing == true && statuses.last?.playbackPending == false
|
||||
}
|
||||
require(statuses.dropFirst(beforeBurst).contains { $0.playing && $0.playbackPending },
|
||||
"An older matching PLAY cannot settle the latest playback intent")
|
||||
require(session.setPlaying(false), "Pause before seek checks")
|
||||
try await until("Pause settles") { statuses.last?.playing == false && statuses.last?.playbackPending == false }
|
||||
peer.holdSeek = true
|
||||
let beforeSeek = statuses.count
|
||||
require(session.seek(24.25), "SEEK accepted")
|
||||
try await until("Old status arrives while SEEK pending") {
|
||||
peer.commands.contains { $0.0 == "SEEK" } && statuses.count >= beforeSeek + 2
|
||||
}
|
||||
require(statuses.last?.seekPending == true && statuses.last?.position == 9.25,
|
||||
"Old status cannot clear an unacknowledged seek or invent position")
|
||||
try await until("Heartbeat is waiting on the controlled clock") { clock.hasDelay(.seconds(5)) }
|
||||
clock.advance(.seconds(5))
|
||||
try await until("Newer status poll replies before held SEEK") {
|
||||
peer.commands.contains { $0.0 == "GET_STATUS" } && statuses.last?.seekPending == true
|
||||
}
|
||||
try peer.releaseSeek()
|
||||
try await until("SEEK request correlation") { statuses.last?.seekPending == false && statuses.last?.position == 24.25 }
|
||||
require(session.setVolume(0.3), "Device volume accepted")
|
||||
try await until("Device volume acknowledged") { statuses.last?.volume == 0.3 }
|
||||
require(session.setSubtitle(nil), "Subtitle off accepted")
|
||||
try await until("Subtitle off acknowledged") { statuses.last?.subtitle == nil }
|
||||
let oldSession = 2
|
||||
try await session.load(media("replacement"), title: "Replacement", position: 3, playing: true, subtitle: nil)
|
||||
let statusCount = statuses.count
|
||||
try peer.status(session: oldSession, content: media().url.absoluteString)
|
||||
try peer.status()
|
||||
try await until("Valid status after stale frame provides an ordered receiver barrier") {
|
||||
statuses.count >= statusCount + 1
|
||||
}
|
||||
require(statuses.count == statusCount + 1 && !disconnected, "Only the valid barrier status is published")
|
||||
try peer.status(state: "IDLE", idle: "FINISHED")
|
||||
try await until("Receiver reports natural end") { statuses.last?.ended == true }
|
||||
let beforeReplay = peer.commands.filter { $0.0 == "LOAD" }.count
|
||||
require(session.setPlaying(true) && session.setPlaying(false), "Ended replay and rapid pause accepted")
|
||||
try await until("Ended replay uses LOAD from zero then honors pause") {
|
||||
peer.commands.filter { $0.0 == "LOAD" }.count == beforeReplay + 1 &&
|
||||
statuses.last?.position == 0 && statuses.last?.playing == false && statuses.last?.ended == false
|
||||
}
|
||||
require(peer.commands.last(where: { $0.0 == "LOAD" })?.1["currentTime"] as? Double == 0,
|
||||
"Default receiver ended media reloaded, not invalid PLAY")
|
||||
require(!session.seek(.nan) && !session.setVolume(2) && !session.setSubtitle(99), "Invalid command input rejected")
|
||||
session.disconnect()
|
||||
try await until("Owned media STOP arrives") { peer.commands.contains { $0.0 == "STOP" } }
|
||||
require(disconnected && errors.isEmpty && peer.failure == nil, "Safe disconnect without spurious cancellation errors")
|
||||
}
|
||||
|
||||
static func launchRegressions(identity: SecIdentity) async throws {
|
||||
for mode in ["cold", "asynchronous", "intermediate"] {
|
||||
let peer = try Receiver(identity: identity)
|
||||
peer.dropLaunch = true
|
||||
try await peer.start()
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(sleep: { try await clock.sleep($0) })
|
||||
var outcome: String?
|
||||
let task = Task {
|
||||
do { try await session.connect(to: peer.device); outcome = "connected" }
|
||||
catch { outcome = error.localizedDescription }
|
||||
}
|
||||
try await until("LAUNCH arrives before delayed receiver status") {
|
||||
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.seconds(5))
|
||||
}
|
||||
let launchID = peer.commands.first { $0.0 == "LAUNCH" }!.1["requestId"] as! Int
|
||||
if mode == "cold" {
|
||||
clock.advance(.seconds(9))
|
||||
try await until("Cold launch advances past ordinary request deadline") {
|
||||
outcome != nil || peer.commands.contains { $0.0 == "PING" } && clock.hasDelay(.seconds(5))
|
||||
}
|
||||
require(outcome == nil, "Cold receiver launch survives the ordinary eight-second control deadline")
|
||||
}
|
||||
let pongCount = peer.commands.filter { $0.0 == "PONG" }.count
|
||||
try peer.receiverStatus(appID: "unrelated-app")
|
||||
try peer.receiverStatus(source: "unrelated-transport")
|
||||
if mode == "intermediate" { try peer.receiverStatus(launchID, appID: nil) }
|
||||
try peer.receiverPing()
|
||||
try await until("Ordered receiver barrier after unrelated and intermediate launch statuses") {
|
||||
outcome != nil || peer.commands.filter { $0.0 == "PONG" }.count == pongCount + 1
|
||||
}
|
||||
require(outcome == nil, "Launch waits for Default Media Receiver rather than adopting another app or rejecting intermediate status")
|
||||
if mode == "cold" {
|
||||
clock.advance(.seconds(11))
|
||||
try await until("Cold receiver remains pending beyond the TLS connection deadline") {
|
||||
outcome != nil || peer.commands.filter { $0.0 == "PING" }.count >= 2 && clock.hasDelay(.seconds(5))
|
||||
}
|
||||
require(outcome == nil, "Successful TLS connection does not impose a twelve-second receiver launch deadline")
|
||||
}
|
||||
try peer.receiverStatus() // Real receivers can broadcast readiness without the LAUNCH request ID.
|
||||
try await until("Asynchronous receiver launch status completes connection") { outcome != nil }
|
||||
require(outcome == "connected", "Default Media Receiver readiness completes launch without the original acknowledgement")
|
||||
await task.value
|
||||
try peer.receiverStatus(launchID) // The original acknowledgement can arrive after readiness.
|
||||
try await session.load(media(), title: "Delayed launch", position: 0, playing: false, subtitle: nil)
|
||||
require(peer.failure == nil, "Late launch acknowledgement does not disturb acquired receiver ownership")
|
||||
session.disconnect(stopReceiver: false)
|
||||
try await until("Delayed launch cancels all clock handles") { clock.pendingCount == 0 }
|
||||
peer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
static func volumeRegressions(identity: SecIdentity) async throws {
|
||||
let peer = try Receiver(identity: identity)
|
||||
peer.holdVolume = true
|
||||
try await peer.start()
|
||||
defer { peer.stop() }
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(sleep: { try await clock.sleep($0) })
|
||||
var statuses: [CastStatus] = []
|
||||
var errors: [String] = []
|
||||
session.onStatus = { statuses.append($0) }
|
||||
session.onError = { errors.append($0) }
|
||||
try await session.connect(to: peer.device)
|
||||
try await session.load(media(), title: "Volume feedback", position: 0, playing: false, subtitle: nil)
|
||||
func volumeID(_ index: Int) -> Int {
|
||||
peer.commands.filter { $0.0 == "SET_VOLUME" }[index].1["requestId"] as! Int
|
||||
}
|
||||
func barrier(_ message: String) async throws {
|
||||
let pongs = peer.commands.filter { $0.0 == "PONG" }.count
|
||||
try peer.status()
|
||||
try peer.receiverPing()
|
||||
try await until(message) { peer.commands.filter { $0.0 == "PONG" }.count == pongs + 1 }
|
||||
}
|
||||
require(session.setVolume(0.2) && session.setVolume(0.8), "Rapid slider commands accepted")
|
||||
try await until("Both slider commands reach receiver") { peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 2 }
|
||||
let beforeBurst = statuses.count
|
||||
try peer.receiverStatus(volumeID(0), level: 0.2)
|
||||
let launchID = peer.commands.first { $0.0 == "LAUNCH" }!.1["requestId"] as! Int
|
||||
try peer.receiverStatus(launchID, level: 0.1)
|
||||
try peer.receiverStatus(level: 0.8)
|
||||
try peer.receiverStatus(level: 0.45)
|
||||
try await barrier("Media status provides barrier after stale volume feedback")
|
||||
require(statuses.last?.volume == 0.8 && statuses.dropFirst(beforeBurst).allSatisfy { $0.volume == 0.8 },
|
||||
"Older command and unsolicited receiver feedback cannot pull the slider away from latest user intent")
|
||||
try peer.receiverStatus(volumeID(1), level: 0.79)
|
||||
try await barrier("Latest slider acknowledgement supplies receiver-authoritative volume")
|
||||
require(statuses.last?.volume == 0.79, "Latest acknowledgement restores the receiver's actual quantized volume")
|
||||
try peer.receiverStatus(volumeID(0), level: 0.2)
|
||||
try peer.receiverStatus(launchID, level: 0.1)
|
||||
try await barrier("Late duplicate old volume acknowledgement is consumed")
|
||||
require(statuses.last?.volume == 0.79, "Old acknowledged request cannot roll volume backward after latest command settles")
|
||||
try peer.receiverStatus(level: 0.4)
|
||||
try await barrier("External TV volume arrives while idle")
|
||||
require(statuses.last?.volume == 0.4, "External TV changes remain authoritative when no latest slider intent is pending")
|
||||
require(session.setVolume(0.6) && session.setVolume(0.9), "Second overlapping slider burst accepted")
|
||||
try await until("Second slider burst reaches receiver") { peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 4 }
|
||||
try peer.receiverStatus(volumeID(3), level: 0.88)
|
||||
try peer.receiverStatus(volumeID(2), level: 0.6)
|
||||
try peer.receiverStatus(level: 0.5)
|
||||
try await barrier("Reverse acknowledgement order and external change processed")
|
||||
require(statuses.last?.volume == 0.5, "Settled latest intent allows external updates even after an older overlapping request")
|
||||
require(session.setVolume(0.7), "Unanswered volume command accepted")
|
||||
try await until("Volume timeout waits on controlled clock") {
|
||||
peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 5 && clock.hasDelay(.seconds(8))
|
||||
}
|
||||
clock.advance(.seconds(8))
|
||||
try await until("Volume timeout releases latest user intent") { !errors.isEmpty }
|
||||
require(statuses.last?.volume == 0.5, "Timed-out volume intent restores the last authoritative receiver value")
|
||||
try peer.receiverStatus(level: 0.7)
|
||||
try await barrier("External volume becomes authoritative after timeout")
|
||||
require(statuses.last?.volume == 0.7, "Unsolicited TV volume resumes after bounded command failure")
|
||||
peer.rejectCommand = true
|
||||
let beforeRejection = errors.count
|
||||
require(session.setVolume(0.4), "Rejected volume command is sent")
|
||||
try await until("Explicit volume rejection releases latest intent") { errors.count > beforeRejection }
|
||||
require(statuses.last?.volume == 0.7, "Rejected slider intent restores authoritative receiver volume")
|
||||
session.disconnect(stopReceiver: false)
|
||||
try await until("Volume regression cancels all clock handles") { clock.pendingCount == 0 }
|
||||
}
|
||||
|
||||
static func connectionDeadline() async throws {
|
||||
let listener = try NWListener(using: .tcp, on: .any)
|
||||
@MainActor final class ListenerState {
|
||||
var accepted: [NWConnection] = []
|
||||
var listening = false
|
||||
}
|
||||
let state = ListenerState()
|
||||
listener.stateUpdateHandler = { event in
|
||||
Task { @MainActor in if case .ready = event { state.listening = true } }
|
||||
}
|
||||
listener.newConnectionHandler = { socket in
|
||||
Task { @MainActor in
|
||||
state.accepted.append(socket)
|
||||
// Accept TCP but never answer the client's TLS handshake.
|
||||
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
listener.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
defer { for socket in state.accepted { socket.cancel() }; listener.cancel() }
|
||||
try await until("Incomplete TLS listener starts") { state.listening }
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(sleep: { try await clock.sleep($0) })
|
||||
let task = Task {
|
||||
try await session.connect(to: .init(id: "stall", name: "Stalled TLS", host: "127.0.0.1",
|
||||
port: listener.port!.rawValue))
|
||||
}
|
||||
try await until("Connection handshake waits on frozen clock") {
|
||||
!state.accepted.isEmpty && clock.hasDelay(.seconds(12))
|
||||
}
|
||||
clock.advance(.seconds(12))
|
||||
do { try await task.value; fatalError("FAIL: stalled TLS handshake succeeded") }
|
||||
catch CastError.timeout { }
|
||||
try await until("Connection deadline cancels all timer handles") { clock.pendingCount == 0 }
|
||||
}
|
||||
|
||||
static func failures(identity: SecIdentity) async throws {
|
||||
for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame",
|
||||
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue"] {
|
||||
let peer = try Receiver(identity: identity)
|
||||
try await peer.start()
|
||||
let clock = CastCheckClock()
|
||||
let session = CastSession(requestTimeout: .milliseconds(400), loadTimeout: .milliseconds(400),
|
||||
heartbeatInterval: mode == "heartbeat" ? .milliseconds(100) : .seconds(5),
|
||||
sleep: { try await clock.sleep($0) })
|
||||
var disconnected = false
|
||||
var errors: [String] = []
|
||||
session.onDisconnect = { _ in disconnected = true }
|
||||
session.onError = { errors.append($0) }
|
||||
if mode == "launch" { peer.rejectLaunch = true }
|
||||
if mode == "launch-timeout" { peer.dropLaunch = true }
|
||||
if mode == "heartbeat" { peer.ignoreHeartbeat = true }
|
||||
if mode == "launch" {
|
||||
do { try await session.connect(to: peer.device); fatalError("FAIL: rejected launch succeeded") }
|
||||
catch CastError.rejected { }
|
||||
} else if mode == "launch-timeout" {
|
||||
let task = Task { try await session.connect(to: peer.device) }
|
||||
try await until("Receiver launch waits on frozen clock") {
|
||||
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.seconds(30))
|
||||
}
|
||||
clock.advance(.seconds(30))
|
||||
do { try await task.value; fatalError("FAIL: unanswered launch succeeded") }
|
||||
catch CastError.timeout { require(disconnected, "Launch deadline releases channel") }
|
||||
} else {
|
||||
try await session.connect(to: peer.device)
|
||||
try await until("Handshake deadline handles cancelled") { clock.pendingCount == 1 }
|
||||
peer.rejectLoad = mode == "load"
|
||||
peer.dropLoad = mode == "deadline" || mode == "cancel"
|
||||
if mode == "load" || mode == "deadline" {
|
||||
let task = Task { try await session.load(media(), title: "Failure", position: 0, playing: true, subtitle: nil) }
|
||||
if mode == "deadline" {
|
||||
try await until("LOAD waits on frozen clock") {
|
||||
peer.commands.contains { $0.0 == "LOAD" } && clock.hasDelay(.milliseconds(400))
|
||||
}
|
||||
clock.advance(.milliseconds(400))
|
||||
}
|
||||
do { try await task.value; fatalError("FAIL: failed load succeeded") }
|
||||
catch CastError.rejected { require(mode == "load", "LOAD rejection correlated") }
|
||||
catch CastError.timeout { require(mode == "deadline", "LOAD deadline bounded") }
|
||||
} else if mode == "cancel" {
|
||||
let task = Task { try await session.load(media(), title: "Cancel", position: 0, playing: true, subtitle: nil) }
|
||||
try await until("LOAD sent before cancellation") { peer.commands.contains { $0.0 == "LOAD" } }
|
||||
task.cancel()
|
||||
do { try await task.value; fatalError("FAIL: cancelled LOAD succeeded") }
|
||||
catch { require(disconnected, "LOAD cancellation releases channel") }
|
||||
} else {
|
||||
try await session.load(media(), title: "Check", position: 0, playing: false, subtitle: nil)
|
||||
switch mode {
|
||||
case "command":
|
||||
peer.dropCommands = true
|
||||
require(session.seek(12), "Unanswered SEEK sent")
|
||||
try await until("SEEK waits on frozen clock") {
|
||||
peer.commands.contains { $0.0 == "SEEK" } && clock.hasDelay(.milliseconds(400))
|
||||
}
|
||||
clock.advance(.milliseconds(400))
|
||||
try await until("Command deadline reports recoverable error") { !errors.isEmpty }
|
||||
require(!disconnected, "Command timeout is not channel failure")
|
||||
case "rejection":
|
||||
peer.rejectCommand = true
|
||||
require(session.setPlaying(true), "Rejected command sent")
|
||||
try await until("Command rejection reports recoverable error") { !errors.isEmpty }
|
||||
require(!disconnected, "Command rejection is not channel failure")
|
||||
case "media-error": try peer.status(state: "IDLE", idle: "ERROR")
|
||||
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
|
||||
case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED")
|
||||
case "frame": peer.invalidFrame()
|
||||
case "ownership": try peer.takeOverApplication()
|
||||
case "heartbeat":
|
||||
try await until("Heartbeat tick is scheduled on frozen clock") { clock.hasDelay(.milliseconds(100)) }
|
||||
clock.advance(.milliseconds(100))
|
||||
try await until("PING sent and next heartbeat tick scheduled") {
|
||||
peer.commands.contains { $0.0 == "PING" } && clock.hasDelay(.milliseconds(100))
|
||||
}
|
||||
clock.advance(.milliseconds(100))
|
||||
case "queue":
|
||||
peer.dropCommands = true
|
||||
var accepted = 0
|
||||
for _ in 0..<40 { if session.setPlaying(true) { accepted += 1 } }
|
||||
require(accepted == 32 && !errors.isEmpty, "Pending command resource bound enforced")
|
||||
default: break
|
||||
}
|
||||
if ["frame", "ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat"].contains(mode) {
|
||||
try await until("\(mode) causes bounded channel failure") { disconnected }
|
||||
}
|
||||
}
|
||||
}
|
||||
session.disconnect(stopReceiver: false)
|
||||
try await until("Cancelled timer handles released") { clock.pendingCount == 0 }
|
||||
peer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
static func playback(identity: SecIdentity, executable: String, file: String) async throws {
|
||||
let peer = try Receiver(identity: identity)
|
||||
peer.verifyRelay = true
|
||||
try await peer.start()
|
||||
defer { peer.stop() }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = [file, String(peer.port)]
|
||||
try process.run()
|
||||
defer { if process.isRunning { process.terminate() } }
|
||||
try await until("Integrated playback subprocess completes", attempts: 18000) { !process.isRunning || peer.failure != nil }
|
||||
require(peer.failure == nil, peer.failure ?? "Simulated receiver accepts integration flow")
|
||||
require(process.terminationStatus == 0, "Player model integration check succeeds")
|
||||
try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } }
|
||||
let load = peer.commands.first { $0.0 == "LOAD" }!.1
|
||||
require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff")
|
||||
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
|
||||
let controls = peer.commands.filter { ["PLAY", "SEEK", "PAUSE", "STOP"].contains($0.0) }
|
||||
require(controls.map(\.0) == ["PLAY", "SEEK", "SEEK", "PAUSE", "STOP"], "Integrated controls ordered")
|
||||
require(controls[1].1["currentTime"] as? Double == 14.25 &&
|
||||
controls[2].1["currentTime"] as? Double == 24.25, "Rapid skips accumulate from the pending target")
|
||||
}
|
||||
|
||||
static func realPeer(_ arguments: [String]) async throws {
|
||||
guard let port = UInt16(arguments[3]), let url = URL(string: arguments[4]) else {
|
||||
fatalError("Usage: cast-session-check --peer HOST PORT MEDIA_URL CONTENT_TYPE")
|
||||
}
|
||||
let session = CastSession()
|
||||
var status: CastStatus?
|
||||
var error: String?
|
||||
session.onStatus = { status = $0 }
|
||||
session.onDisconnect = { error = $0 }
|
||||
session.onError = { error = $0 }
|
||||
defer { session.disconnect() }
|
||||
try await session.connect(to: .init(id: "smoke", name: "Smoke receiver", host: arguments[2], port: port))
|
||||
try await session.load(.init(url: url, contentType: arguments[5], duration: 0, live: false, tracks: []),
|
||||
title: "Terebi Cast smoke", position: 0, playing: false, subtitle: nil)
|
||||
require(status != nil, "Real receiver acknowledges LOAD")
|
||||
require(session.setPlaying(true), "Real receiver PLAY accepted")
|
||||
try await until("Real receiver plays", attempts: 1500) { status?.playing == true || error != nil }
|
||||
require(error == nil && status?.playing == true, "Real receiver acknowledges PLAY")
|
||||
require(session.setPlaying(false), "Real receiver PAUSE accepted")
|
||||
try await until("Real receiver pauses", attempts: 1500) { status?.playing == false || error != nil }
|
||||
require(error == nil && status?.playing == false, "Real receiver acknowledges PAUSE")
|
||||
print("PASS: real peer TLS Default Media Receiver LOAD/PLAY/PAUSE (URLs intentionally omitted)")
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
if CommandLine.arguments.count == 6, CommandLine.arguments[1] == "--peer" {
|
||||
try await realPeer(CommandLine.arguments)
|
||||
return
|
||||
}
|
||||
try codec()
|
||||
let (identity, directory) = try testIdentity()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--launch-regressions" {
|
||||
try await launchRegressions(identity: identity)
|
||||
print("PASS: cold and asynchronous Cast launch readiness, unrelated app protection, late acknowledgement")
|
||||
return
|
||||
}
|
||||
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--volume-regressions" {
|
||||
try await volumeRegressions(identity: identity)
|
||||
print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout")
|
||||
return
|
||||
}
|
||||
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
|
||||
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
||||
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
|
||||
} else {
|
||||
try await behavior(identity: identity)
|
||||
try await launchRegressions(identity: identity)
|
||||
try await volumeRegressions(identity: identity)
|
||||
try await failures(identity: identity)
|
||||
try await connectionDeadline()
|
||||
print("PASS: bounded Cast framing, self-signed TLS launch/load/control, correlation, stale ownership, queue, deadlines, heartbeat, cancellation")
|
||||
}
|
||||
}
|
||||
}
|
||||
+338
-81
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
@@ -68,14 +69,14 @@ struct CompactWindowChecks {
|
||||
"overlapping magnetic zones choose the closest edge, not first edge")
|
||||
|
||||
let usableScreen = NSRect(x: -1_200, y: 50, width: 1_200, height: 800)
|
||||
let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen)
|
||||
let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen, width: 320)
|
||||
try require(sameFrame(landscape, NSRect(x: -336, y: 654, width: 320, height: 180)),
|
||||
"16:9 enters at top right with 320pt width and 16pt inset")
|
||||
let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen)
|
||||
let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen, width: 320)
|
||||
try require(sameFrame(portrait, NSRect(x: -336, y: 834 - 5_120.0 / 9.0, width: 320, height: 5_120.0 / 9.0)),
|
||||
"portrait preserves exact aspect when it fits")
|
||||
for aspect in [16.0 / 9.0, 9.0 / 16.0, 100.0, 0.01] {
|
||||
let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen)
|
||||
let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen, width: 320)
|
||||
try require(frame.origin.x.isFinite && frame.origin.y.isFinite
|
||||
&& frame.width.isFinite && frame.height.isFinite,
|
||||
"initial geometry is finite for aspect \(aspect)")
|
||||
@@ -88,92 +89,187 @@ struct CompactWindowChecks {
|
||||
"aspect \(aspect) stays anchored top right")
|
||||
}
|
||||
let smallScreen = NSRect(x: 0, y: 0, width: 300, height: 400)
|
||||
let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen)
|
||||
let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen, width: 320)
|
||||
try require(sameFrame(bounded, NSRect(x: 16, y: 233.25, width: 268, height: 150.75)),
|
||||
"target width shrinks to usable bounds without distorting ordinary aspect")
|
||||
}
|
||||
|
||||
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")
|
||||
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
|
||||
static func settled(_ controller: CompactWindowController, compact: Bool,
|
||||
checking: () throws -> Void = {}) throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while controller.isTransitioning {
|
||||
try checking()
|
||||
try require(ContinuousClock.now < deadline, "native compact transition completes")
|
||||
CFRunLoopRunInMode(.defaultMode, 0.01, false)
|
||||
}
|
||||
// Creating a window does not display it: never orderFront, makeKey, or enter native fullscreen.
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
try checking()
|
||||
try require(controller.isCompact == compact, "settled mode matches the latest destination")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct WindowState {
|
||||
let frame: NSRect
|
||||
let minSize: NSSize
|
||||
let contentMinSize: NSSize
|
||||
let aspect: NSSize
|
||||
let style: NSWindow.StyleMask
|
||||
let titleVisibility: NSWindow.TitleVisibility
|
||||
let transparentTitlebar: Bool
|
||||
let collection: NSWindow.CollectionBehavior
|
||||
let level: NSWindow.Level
|
||||
let hides: Bool
|
||||
let drag: Bool
|
||||
|
||||
init(_ window: NSWindow) {
|
||||
frame = window.frame
|
||||
minSize = window.minSize
|
||||
contentMinSize = window.contentMinSize
|
||||
aspect = window.aspectRatio
|
||||
style = window.styleMask
|
||||
titleVisibility = window.titleVisibility
|
||||
transparentTitlebar = window.titlebarAppearsTransparent
|
||||
collection = window.collectionBehavior
|
||||
level = window.level
|
||||
hides = window.hidesOnDeactivate
|
||||
drag = window.isMovableByWindowBackground
|
||||
}
|
||||
|
||||
func requireMatches(_ window: NSWindow) throws {
|
||||
try CompactWindowChecks.require(CompactWindowChecks.sameFrame(window.frame, frame),
|
||||
"restore recovers original normal geometry")
|
||||
try CompactWindowChecks.require(window.minSize == minSize && window.contentMinSize == contentMinSize
|
||||
&& window.aspectRatio == aspect, "restore recovers aspect and both minima")
|
||||
try CompactWindowChecks.require(window.styleMask == style && window.collectionBehavior == collection
|
||||
&& window.level == level, "restore recovers style, Spaces policy, and level")
|
||||
try CompactWindowChecks.require(window.titleVisibility == titleVisibility
|
||||
&& window.titlebarAppearsTransparent == transparentTitlebar,
|
||||
"restore recovers original native chrome")
|
||||
try CompactWindowChecks.require(window.hidesOnDeactivate == hides
|
||||
&& window.isMovableByWindowBackground == drag,
|
||||
"restore recovers deactivate and background drag behavior")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowFixture {
|
||||
let window: NSWindow
|
||||
let content: NSView
|
||||
let sentinel: NSView
|
||||
let sentinelFrame: NSRect
|
||||
let original: WindowState
|
||||
let identity: ObjectIdentifier
|
||||
|
||||
init() throws {
|
||||
// Never orderFront, makeKey, or create an actual native fullscreen Space.
|
||||
window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
guard let content = window.contentView else { throw Failure(description: "FAIL: missing content view") }
|
||||
let sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
||||
content.addSubview(sentinel)
|
||||
let sentinelFrame = sentinel.frame
|
||||
window.minSize = NSSize(width: 300, height: 180)
|
||||
window.contentMinSize = NSSize(width: 220, height: 120)
|
||||
window.aspectRatio = NSSize(width: 4, height: 3)
|
||||
window.collectionBehavior = [.managed, .fullScreenPrimary]
|
||||
window.level = .normal
|
||||
window.hidesOnDeactivate = true
|
||||
window.isMovableByWindowBackground = false
|
||||
let originalFrame = window.frame
|
||||
let originalMinSize = window.minSize
|
||||
let originalContentMinSize = window.contentMinSize
|
||||
let originalAspect = window.aspectRatio
|
||||
let originalStyle = window.styleMask
|
||||
let originalTitleVisibility = window.titleVisibility
|
||||
let originalTransparentTitlebar = window.titlebarAppearsTransparent
|
||||
let originalCollection = window.collectionBehavior
|
||||
let originalLevel = window.level
|
||||
let originalHides = window.hidesOnDeactivate
|
||||
let originalDrag = window.isMovableByWindowBackground
|
||||
let identity = ObjectIdentifier(window)
|
||||
let controller = CompactWindowController()
|
||||
defer { controller.restore(resumeFullScreen: false) }
|
||||
controller.attach(window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
|
||||
try require(controller.snappingEnabled, "fresh isolated preferences default snapping on")
|
||||
window.isReleasedWhenClosed = false
|
||||
guard let content = window.contentView else {
|
||||
throw Failure(description: "FAIL: missing content view")
|
||||
}
|
||||
self.content = content
|
||||
sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
||||
content.addSubview(sentinel)
|
||||
sentinelFrame = sentinel.frame
|
||||
window.minSize = NSSize(width: 300, height: 180)
|
||||
window.contentMinSize = NSSize(width: 220, height: 120)
|
||||
window.aspectRatio = NSSize(width: 4, height: 3)
|
||||
window.collectionBehavior = [.managed, .fullScreenPrimary]
|
||||
window.level = .normal
|
||||
window.hidesOnDeactivate = true
|
||||
window.isMovableByWindowBackground = false
|
||||
original = WindowState(window)
|
||||
identity = ObjectIdentifier(window)
|
||||
}
|
||||
|
||||
func preservedContent() throws {
|
||||
try require(ObjectIdentifier(window) == identity && sentinel.window === window,
|
||||
"compact mode keeps the actual rendering window")
|
||||
try require(window.contentView === content && sentinel.superview === content
|
||||
&& content.subviews.count == 1 && sameFrame(sentinel.frame, sentinelFrame),
|
||||
"compact mode preserves content and attached sentinel without reparenting")
|
||||
try require(!window.isVisible, "checks never display a window")
|
||||
try CompactWindowChecks.require(ObjectIdentifier(window) == identity && sentinel.window === window,
|
||||
"compact mode keeps the actual rendering window")
|
||||
try CompactWindowChecks.require(window.contentView === content && sentinel.superview === content
|
||||
&& content.subviews.count == 1
|
||||
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
|
||||
"compact mode preserves content and attached sentinel without reparenting")
|
||||
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow,
|
||||
"checks never display or key a window")
|
||||
}
|
||||
|
||||
func restored() throws {
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "restore finishes in ordinary mode")
|
||||
try require(sameFrame(window.frame, originalFrame), "restore recovers original normal geometry")
|
||||
try require(window.minSize == originalMinSize && window.contentMinSize == originalContentMinSize
|
||||
&& window.aspectRatio == originalAspect, "restore recovers aspect and both minima")
|
||||
try require(window.styleMask == originalStyle && window.collectionBehavior == originalCollection
|
||||
&& window.level == originalLevel, "restore recovers style, Spaces policy, and level")
|
||||
try require(window.titleVisibility == originalTitleVisibility
|
||||
&& window.titlebarAppearsTransparent == originalTransparentTitlebar,
|
||||
"restore recovers original native chrome")
|
||||
try require(window.hidesOnDeactivate == originalHides
|
||||
&& window.isMovableByWindowBackground == originalDrag,
|
||||
"restore recovers deactivate and background drag behavior")
|
||||
try original.requireMatches(window)
|
||||
try preservedContent()
|
||||
}
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isCompact && !controller.isTransitioning, "windowed toggle enters compact synchronously")
|
||||
try require(window.level == .floating && !window.hidesOnDeactivate, "compact floats and remains visible on deactivate")
|
||||
try require(window.collectionBehavior.contains([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||
try require(window.collectionBehavior.intersection([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||
"compact removes incompatible primary and auxiliary policies")
|
||||
try require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum")
|
||||
try require(window.isMovableByWindowBackground, "compact allows background dragging")
|
||||
try require(window.canBecomeKey, "compact window still accepts keyboard playback controls")
|
||||
try preservedContent()
|
||||
func compact(aspect: Double) throws {
|
||||
try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate,
|
||||
"compact floats and remains visible on deactivate")
|
||||
try CompactWindowChecks.require(window.collectionBehavior.contains(
|
||||
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||
try CompactWindowChecks.require(window.collectionBehavior.intersection(
|
||||
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||
"compact removes incompatible primary and auxiliary policies")
|
||||
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
|
||||
"compact uses 160x90 content minimum")
|
||||
try CompactWindowChecks.require(window.isMovableByWindowBackground,
|
||||
"compact allows background dragging")
|
||||
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
|
||||
try CompactWindowChecks.require(window.titleVisibility == .hidden
|
||||
&& window.titlebarAppearsTransparent,
|
||||
"compact hides native title chrome")
|
||||
let visible = (window.screen ?? NSScreen.main!).visibleFrame
|
||||
try CompactWindowChecks.require(CompactWindowChecks.close(window.frame.maxX, visible.maxX - 16)
|
||||
&& CompactWindowChecks.close(window.frame.maxY, visible.maxY - 16)
|
||||
&& CompactWindowChecks.close(window.frame.width / window.frame.height,
|
||||
CGFloat(aspect)),
|
||||
"compact enters at inset top right with playback aspect")
|
||||
try preservedContent()
|
||||
}
|
||||
}
|
||||
|
||||
let visible = (window.screen ?? screen).visibleFrame
|
||||
try require(close(window.frame.maxX, visible.maxX - 16)
|
||||
&& close(window.frame.maxY, visible.maxY - 16)
|
||||
&& close(window.frame.width / window.frame.height, 16.0 / 9.0),
|
||||
"actual compact window enters at inset top right with playback aspect")
|
||||
// A later real native round-trip gives canceled animations a completion opportunity without
|
||||
// guessing elapsed animation time. Watch the interrupted window throughout and after it.
|
||||
static func nativeCompletionOpportunity(checking: () throws -> Void) throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { false })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
|
||||
try settled(controller, compact: true, checking: checking)
|
||||
try fixture.compact(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false, checking: checking)
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
static func windowBehavior() throws {
|
||||
_ = NSApplication.shared
|
||||
guard NSScreen.main != nil else {
|
||||
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
||||
}
|
||||
let fixture = try WindowFixture()
|
||||
let window = fixture.window
|
||||
let controller = CompactWindowController(reduceMotion: { false })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
window.close()
|
||||
}
|
||||
controller.attach(window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
|
||||
try require(controller.snappingEnabled, "isolated controller defaults snapping on")
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isCompact && controller.isTransitioning,
|
||||
"entry presents the compact destination while native geometry settles")
|
||||
try fixture.preservedContent()
|
||||
try settled(controller, compact: true)
|
||||
try fixture.compact(aspect: 16.0 / 9.0)
|
||||
|
||||
let visible = (window.screen ?? NSScreen.main!).visibleFrame
|
||||
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
|
||||
width: window.frame.width, height: window.frame.height)
|
||||
window.setFrame(nearEdge, display: false)
|
||||
@@ -182,13 +278,13 @@ 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(snappingEnabled: false)
|
||||
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { 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(snappingEnabled: false)
|
||||
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { 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)
|
||||
@@ -200,26 +296,187 @@ struct CompactWindowChecks {
|
||||
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
|
||||
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0),
|
||||
"actual compact window follows changed playback aspect")
|
||||
try preservedContent()
|
||||
try fixture.preservedContent()
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Enter -> exit before entry completes: the original normal snapshot must survive.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "entry is in flight before its destination changes")
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Exit -> enter keeps the newest aspect and must not save an intermediate restore frame.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try settled(controller, compact: true)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(!controller.isCompact && controller.isTransitioning,
|
||||
"exit presents the ordinary destination while native geometry settles")
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try settled(controller, compact: true)
|
||||
try fixture.compact(aspect: 4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Alternating requests within one native animation settle only at the latest destination.
|
||||
for compactDestination in [false, true] {
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "alternating requests interrupt native entry")
|
||||
for _ in 0..<(compactDestination ? 6 : 5) {
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
}
|
||||
try settled(controller, compact: compactDestination)
|
||||
if compactDestination {
|
||||
try fixture.compact(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false)
|
||||
}
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Urgent teardown is immediate; old native completion cannot touch its restored snapshot.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "urgent restore interrupts native entry")
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "urgent restore settles immediately")
|
||||
try fixture.restored()
|
||||
try nativeCompletionOpportunity {
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"stale completion cannot revive an urgently restored session")
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
let replacement = try WindowFixture()
|
||||
defer { replacement.window.close() }
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "attachment interrupts native entry")
|
||||
controller.attach(replacement.window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
|
||||
try fixture.restored()
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try settled(controller, compact: true) { try fixture.restored() }
|
||||
try replacement.compact(aspect: 4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false) { try fixture.restored() }
|
||||
try replacement.restored()
|
||||
|
||||
controller.attach(window)
|
||||
// Replay AppKit's failure lifecycle without creating a visible fullscreen Space.
|
||||
NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning && !controller.isCompact,
|
||||
"compact entry waits for an in-flight native fullscreen transition")
|
||||
window.delegate?.windowDidFailToEnterFullScreen?(window)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
NotificationCenter.default.post(name: NSWindow.willExitFullScreenNotification, object: window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
window.delegate?.windowDidFailToExitFullScreen?(window)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "close interrupts native entry")
|
||||
window.close()
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session")
|
||||
let closedState = WindowState(window)
|
||||
controller.attach(replacement.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
func closedWindowUnchanged() throws {
|
||||
try closedState.requireMatches(window)
|
||||
try fixture.preservedContent()
|
||||
}
|
||||
try settled(controller, compact: true, checking: closedWindowUnchanged)
|
||||
try replacement.compact(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false, checking: closedWindowUnchanged)
|
||||
try replacement.restored()
|
||||
}
|
||||
|
||||
static func rememberedSize() throws {
|
||||
let suite = "terebi.compact-size-check.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(defaults: defaults, reduceMotion: { true })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
var resized = fixture.window.frame
|
||||
resized.size = NSSize(width: 240, height: 135)
|
||||
fixture.window.setFrame(resized, display: false)
|
||||
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try fixture.restored()
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(close(fixture.window.frame.width, 240) && close(fixture.window.frame.height, 135),
|
||||
"reentering compact restores the manually chosen size")
|
||||
|
||||
// Programmatic fitting and ordinary-window resizing must not replace the preference.
|
||||
resized.size = NSSize(width: 320, height: 180)
|
||||
fixture.window.setFrame(resized, display: false)
|
||||
controller.updateAspect(4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try fixture.restored()
|
||||
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
|
||||
|
||||
let replacement = try WindowFixture()
|
||||
let reloaded = CompactWindowController(defaults: UserDefaults(suiteName: suite)!, reduceMotion: { true })
|
||||
defer {
|
||||
reloaded.restore(resumeFullScreen: false, animated: false)
|
||||
replacement.window.close()
|
||||
}
|
||||
reloaded.attach(replacement.window)
|
||||
reloaded.toggle(aspect: 4.0 / 3.0)
|
||||
try require(close(replacement.window.frame.width, 240) && close(replacement.window.frame.height, 180),
|
||||
"a recreated controller restores preferred width with the new video's aspect")
|
||||
try replacement.compact(aspect: 4.0 / 3.0)
|
||||
reloaded.restore(resumeFullScreen: false)
|
||||
try replacement.restored()
|
||||
}
|
||||
|
||||
static func reducedMotion() throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { true })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion enters with geometry and controls already settled")
|
||||
try fixture.compact(aspect: 16.0 / 9.0)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion restores without a pending frame animation")
|
||||
try fixture.restored()
|
||||
try nativeCompletionOpportunity {
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion leaves no completion that can revive compact state")
|
||||
try fixture.restored()
|
||||
}
|
||||
}
|
||||
|
||||
static func main() throws {
|
||||
try geometry()
|
||||
try windowBehavior()
|
||||
try reducedMotion()
|
||||
try rememberedSize()
|
||||
print("Compact window checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,12 @@ struct ConfigurationChecks {
|
||||
"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")
|
||||
for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] {
|
||||
let progress = root.appendingPathComponent("compact-progress-\(value).toml")
|
||||
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
|
||||
try require(try PlayerConfiguration.load(from: progress).compactTitleProgress == expected,
|
||||
"compact title format \(value) loads from a partial UI section")
|
||||
}
|
||||
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"]] {
|
||||
@@ -219,6 +225,8 @@ struct ConfigurationChecks {
|
||||
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
||||
("integer", "[playback]\nautomatic_quality = 1\n", []),
|
||||
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
|
||||
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
|
||||
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
|
||||
("table", "[playback.automatic_quality]\nenabled = true\n", ["automatic_quality"]),
|
||||
("ui-scalar", "ui = true\n", ["ui"]),
|
||||
("keys-scalar", "keys = true\n", ["keys"]),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct ExternalSubtitleChecks {
|
||||
struct Failure: Error, CustomStringConvertible { let description: String }
|
||||
|
||||
static func require(_ condition: @autoclosure () throws -> Bool, _ message: String) throws {
|
||||
guard try condition() else { throw Failure(description: "FAIL: \(message)") }
|
||||
}
|
||||
|
||||
static func reject(_ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch is Failure { throw Failure(description: "FAIL: rejection assertion") }
|
||||
catch { return }
|
||||
throw Failure(description: "FAIL: malformed input accepted")
|
||||
}
|
||||
|
||||
static let fixture = """
|
||||
WEBVTT example
|
||||
|
||||
NOTE ignored comment
|
||||
not a cue
|
||||
|
||||
STYLE
|
||||
::cue { color: white; }
|
||||
|
||||
REGION
|
||||
id:bottom
|
||||
width:80%
|
||||
|
||||
cue-one
|
||||
00:01.000 --> 00:04.000 line:90% position:50%,center align:center size:80% region:bottom
|
||||
<v Speaker><b>Hello & goodbye</b></v>
|
||||
second <c.yellow>line</c> <3 !
|
||||
|
||||
cue-two
|
||||
00:02.000 --> 00:03.000 vertical:rl
|
||||
Overlap <00:02.500><i>text</i>
|
||||
|
||||
00:04.000 --> 00:05.000
|
||||
Next
|
||||
|
||||
"""
|
||||
|
||||
static func main() async throws {
|
||||
let document = try WebVTTDocument.parse("\u{FEFF}" + fixture.replacingOccurrences(of: "\n", with: "\r\n"))
|
||||
let first = "Hello & goodbye\nsecond line <3 !"
|
||||
try require(document.cues.count == 3, "BOM, CRLF, identifiers and metadata blocks")
|
||||
try require(document.text(at: 0.999).isEmpty, "before first cue")
|
||||
try require(document.text(at: 1) == first, "inclusive start and plain-text markup/entities")
|
||||
try require(document.text(at: 2.5) == first + "\nOverlap text", "simultaneous overlapping cues")
|
||||
try require(document.text(at: 3) == first, "exclusive overlap end")
|
||||
try require(document.text(at: 4) == "Next", "adjacent exclusive end/inclusive start")
|
||||
try require(document.text(at: 10).isEmpty, "after final cue")
|
||||
try require(document.text(at: 2) == first + "\nOverlap text", "backward seek after final cue")
|
||||
try require(document.text(at: .nan).isEmpty && document.text(at: -1).isEmpty, "invalid acknowledged positions")
|
||||
let nested = try WebVTTDocument.parse("WEBVTT\n\n00:00.000 --> 01:00.000\nlong\n\n00:02.000 --> 00:03.000\nshort\n")
|
||||
try require(nested.text(at: 4) == "long", "earlier long cue survives later cue ending")
|
||||
try require(try WebVTTDocument.parse("WEBVTT\n\n01:02:03.004 --> 01:02:04.000\nhour\n").text(at: 3723.004) == "hour", "hour timestamps")
|
||||
let zeroDuration = try WebVTTDocument.parse("WEBVTT\n\n00:01.000 --> 00:01.000\nnever visible\n\n00:02.000 --> 00:03.000\nvalid later cue\n")
|
||||
try require(zeroDuration.text(at: 0.999).isEmpty && zeroDuration.text(at: 1).isEmpty && zeroDuration.text(at: 1.001).isEmpty, "zero-duration cue is never active")
|
||||
try require(zeroDuration.text(at: 2) == "valid later cue", "zero-duration cue does not invalidate later subtitles")
|
||||
try require(zeroDuration.text(at: 4).isEmpty && zeroDuration.text(at: 1).isEmpty, "zero-duration cue stays inactive after backward seek")
|
||||
for malformed in [
|
||||
"not VTT", "WEBVTT\n00:01.000 --> 00:02.000\nmissing separator",
|
||||
"WEBVTT\n\n00:61.000 --> 00:62.000\nbad time",
|
||||
"WEBVTT\n\n00:02.000 --> 00:01.000\nreversed interval",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000 size:101%\nbad setting",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000\n<b unfinished",
|
||||
"WEBVTT\n\norphan identifier", "WEBVTT\n\n00:01.000 --> 00:02.000\ncue\n\nSTYLE\nlate style"
|
||||
] { try reject { _ = try WebVTTDocument.parse(malformed) } }
|
||||
try reject { _ = try WebVTTDocument.parse(String(repeating: "x", count: WebVTTDocument.byteLimit + 1)) }
|
||||
|
||||
let headers = "Authorization: Bearer fixture-secret\r\nReferer: https://provider.example/\r\nX-Track: english\r\n"
|
||||
let request = try WebVTTDocument.request(url: "https://provider.example/track.jpeg", headers: headers)
|
||||
try require(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-secret", "resource authorization header")
|
||||
try require(request.value(forHTTPHeaderField: "X-Track") == "english", "resource-specific custom header")
|
||||
let whitespace = try WebVTTDocument.request(url: "https://provider.example/track", headers: "X-Exact: padded \t \r\n")
|
||||
try require(whitespace.value(forHTTPHeaderField: "X-Exact") == " padded \t ", "preserve header value whitespace after generated separator")
|
||||
for malformed in ["X: a\nInjected: value\r\n", "X: a\rInjected: value\r\n", "X: a\r\nX: b\r\n", "X: a", ": a\r\n"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: "https://provider.example/track", headers: malformed) }
|
||||
}
|
||||
for url in ["file:///tmp/track", "https://user:secret@provider.example/track", "https://provider.example/track#fragment"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: url, headers: "") }
|
||||
}
|
||||
let source = URL(string: "https://provider.example/track.jpeg")!
|
||||
try require(WebVTTDocument.permitsRedirect(from: source, to: URL(string: "https://PROVIDER.example:443/other")!), "same origin includes default port")
|
||||
for target in ["https://cdn.example/track", "http://provider.example/track", "https://provider.example:8443/track", "https://user:secret@provider.example/track"] {
|
||||
try require(!WebVTTDocument.permitsRedirect(from: source, to: URL(string: target)!), "cross-origin or credentialed redirect rejection")
|
||||
}
|
||||
|
||||
let server = try SubtitleFixtureServer(body: fixture)
|
||||
defer { server.stop() }
|
||||
let loaded = try await WebVTTDocument.load(url: server.url("/same.jpeg"), headers: headers)
|
||||
try require(loaded.cues == document.cues, "load JPEG-named, image/jpeg VTT via same-origin redirect")
|
||||
let requests = server.requests
|
||||
try require(requests.count == 2 && requests.allSatisfy { $0.contains("Bearer fixture-secret") && $0.contains("english") }, "resource headers preserved only on same origin")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/cross.jpeg"), headers: headers)
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch is Failure {
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
try require(!message.contains("fixture-secret") && !message.contains("127.0.0.1"), "sanitized visible transport error")
|
||||
}
|
||||
try require(server.requests.count == 3, "cross-origin redirect never sends credential-bearing request")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/oversized.jpeg"), headers: "")
|
||||
throw Failure(description: "FAIL: oversized response accepted")
|
||||
} catch is Failure { throw Failure(description: "FAIL: oversized response accepted") }
|
||||
catch { }
|
||||
print("External subtitle checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
/// A loopback-only HTTP fixture; no external services, dates, credentials or frameworks.
|
||||
private final class SubtitleFixtureServer: @unchecked Sendable {
|
||||
private let descriptor: Int32
|
||||
private let port: UInt16
|
||||
private let body: String
|
||||
private let lock = NSLock()
|
||||
private var recorded: [String] = []
|
||||
private let ended = DispatchSemaphore(value: 0)
|
||||
|
||||
var requests: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return recorded
|
||||
}
|
||||
|
||||
init(body: String) throws {
|
||||
self.body = body
|
||||
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
|
||||
self.descriptor = descriptor
|
||||
guard descriptor >= 0 else { throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture socket") }
|
||||
var address = sockaddr_in()
|
||||
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||
let bound = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) }
|
||||
}
|
||||
var size = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let named = withUnsafeMutablePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &size) }
|
||||
}
|
||||
guard bound == 0, named == 0, listen(descriptor, 4) == 0 else {
|
||||
close(descriptor)
|
||||
throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture listener")
|
||||
}
|
||||
port = UInt16(bigEndian: address.sin_port)
|
||||
DispatchQueue.global().async { self.serve() }
|
||||
}
|
||||
|
||||
func url(_ path: String) -> String { "http://127.0.0.1:\(port)\(path)" }
|
||||
|
||||
func stop() {
|
||||
shutdown(descriptor, SHUT_RDWR)
|
||||
close(descriptor)
|
||||
ended.wait()
|
||||
}
|
||||
|
||||
private func serve() {
|
||||
defer { ended.signal() }
|
||||
while true {
|
||||
let client = accept(descriptor, nil, nil)
|
||||
guard client >= 0 else { return }
|
||||
var noSignal: Int32 = 1
|
||||
_ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var data = Data()
|
||||
while data.count < 65536 {
|
||||
let count = recv(client, &buffer, buffer.count, 0)
|
||||
if count <= 0 { break }
|
||||
data.append(contentsOf: buffer.prefix(count))
|
||||
if String(decoding: data, as: UTF8.self).contains("\r\n\r\n") { break }
|
||||
}
|
||||
let request = String(decoding: data, as: UTF8.self)
|
||||
lock.lock()
|
||||
recorded.append(request)
|
||||
lock.unlock()
|
||||
let path = request.split(separator: " ").dropFirst().first ?? ""
|
||||
let response: String
|
||||
switch path {
|
||||
case "/same.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: /track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/cross.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: http://localhost:\(port)/track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/oversized.jpeg": response = "HTTP/1.1 200 OK\r\nContent-Length: \(WebVTTDocument.byteLimit + 1)\r\nConnection: close\r\n\r\n"
|
||||
default: response = "HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
|
||||
}
|
||||
let bytes = Array(response.utf8)
|
||||
bytes.withUnsafeBytes { raw in
|
||||
var offset = 0
|
||||
while offset < raw.count {
|
||||
let count = send(client, raw.baseAddress!.advanced(by: offset), raw.count - offset, 0)
|
||||
if count <= 0 { break }
|
||||
offset += count
|
||||
}
|
||||
}
|
||||
close(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
-1
@@ -1,6 +1,9 @@
|
||||
#include "../Sources/Core/player_macos.h"
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <MetalKit/MetalKit.h>
|
||||
extern "C" {
|
||||
#include <libavutil/log.h>
|
||||
}
|
||||
#import <dispatch/dispatch.h>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
@@ -135,7 +138,9 @@ static bool openNetwork(Player *player, const std::string &base, const std::stri
|
||||
}
|
||||
|
||||
static void closed(Player *player) {
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source");
|
||||
// A close submitted from PLAYER_ERROR may still publish the previous error
|
||||
// until the worker acknowledges it; require the complete clean state below.
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source", true);
|
||||
require(state.position == 0 && state.duration == 0 && !state.has_audio && !state.has_video &&
|
||||
!state.can_seek && !state.buffering && !state.seek_pending && !state.error[0],
|
||||
"close clears all network/media/seek/error state");
|
||||
@@ -287,6 +292,94 @@ static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
std::puts("PASS native HLS: selected headers/recorded seekability/paused seek/audio-video resume");
|
||||
}
|
||||
|
||||
static bool openResolved(Player *player, const std::string &base, const std::string &scenario,
|
||||
bool credentials = true) {
|
||||
const auto url = base + "/resolved/" + scenario + "/master.m3u8?token=part%2Fone+two%26tail";
|
||||
const std::string headers = credentials ?
|
||||
"X-Terebi-Fixture: combined\r\nAuthorization: Bearer resolved-header-secret\r\n" :
|
||||
"X-Terebi-Fixture: combined\r\n";
|
||||
PlayerNetworkSource source{url.c_str(), nullptr, headers.c_str(), nullptr,
|
||||
4, false, "miruro:fixture_opaque-ID:1", 0};
|
||||
return player_open_network(player, &source);
|
||||
}
|
||||
|
||||
static void resolvedHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
for (const std::string scenario : {"encrypted", "fmp4", "image-encrypted", "image-fmp4"}) {
|
||||
require(openResolved(player, base, scenario), "resolved HLS asynchronous open accepted");
|
||||
require(player_play(player), "resolved HLS play accepted");
|
||||
auto state = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PLAYING && !s.buffering && s.position > .2;
|
||||
}, "resolved HLS audio/video decode");
|
||||
require(state.has_audio && state.has_video && state.can_seek,
|
||||
"encrypted/fMP4 HLS provides decoded audio, video and seekability");
|
||||
require(player_pause(player), "pause resolved HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "resolved HLS pause");
|
||||
seekCompleted(player, 2.5, PLAYER_PAUSED);
|
||||
auto revision = player_get_state(player).revision;
|
||||
require(player_step_frame(player, 1), "step an actually decoded HLS video frame after seek");
|
||||
state = until(player, [revision](auto s) {
|
||||
return s.status == PLAYER_PAUSED && !s.seek_pending && s.revision != revision && s.position > 2.5;
|
||||
}, "resolved HLS decoded frame acknowledgement");
|
||||
require(state.position < 2.6, "HLS seek selects the adjacent 24fps decoded frame");
|
||||
[view draw];
|
||||
require(player_play(player), "resume resolved HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.65; },
|
||||
"resolved HLS audio clock resumes after frame step");
|
||||
seekCompleted(player, 1.25, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.4; },
|
||||
"resolved HLS playing backward seek");
|
||||
require(player_close(player), "close resolved HLS");
|
||||
closed(player);
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
|
||||
[[NSString stringWithUTF8String:scenario.c_str()]];
|
||||
for (NSString *kind in @[@"master", @"variant", @"segment",
|
||||
scenario.ends_with("encrypted") ? @"key" : @"init"]) {
|
||||
require([source[kind][@"requests"] unsignedLongLongValue] > 0 &&
|
||||
[source[kind][@"denied"] unsignedLongLongValue] == 0,
|
||||
"required headers and unmodified query token reach every HLS resource class");
|
||||
}
|
||||
}
|
||||
|
||||
for (const std::string scenario : {"redirect-master", "redirect-variant", "redirect-segment",
|
||||
"redirect-key", "redirect-init", "redirect-same-master",
|
||||
"external-variant", "external-segment", "external-key", "external-init",
|
||||
"missing"}) {
|
||||
require(openResolved(player, base, scenario, scenario != "missing"), "unsafe HLS request accepted asynchronously");
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_ERROR; },
|
||||
"unsafe HLS transport rejected", true);
|
||||
require(state.error[0] && std::string(state.error).find("http") == std::string::npos &&
|
||||
std::string(state.error).find("resolved-header-secret") == std::string::npos,
|
||||
"application retains meaningful transport error without credentials or URLs");
|
||||
if (scenario.starts_with("redirect-")) {
|
||||
const auto kind = scenario == "redirect-same-master" ? "master" : scenario.substr(9);
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
|
||||
[[NSString stringWithUTF8String:scenario.c_str()]];
|
||||
require([source[[NSString stringWithUTF8String:kind.c_str()]][@"redirects"] unsignedLongLongValue] > 0,
|
||||
"redirect isolation actually exercises each HLS redirect resource");
|
||||
}
|
||||
if (scenario == "missing") {
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"][@"missing"];
|
||||
require([source[@"master"][@"denied"] unsignedLongLongValue] > 0,
|
||||
"fixture explicitly fails a request missing a required credential");
|
||||
}
|
||||
require(player_close(player), "close rejected resolved source");
|
||||
closed(player);
|
||||
}
|
||||
NSDictionary *sink = control(base, "/control/resolved-stats")[@"sink"];
|
||||
require([sink[@"requests"] unsignedLongLongValue] == 0 &&
|
||||
[sink[@"credentials"] unsignedLongLongValue] == 0,
|
||||
"neither redirects nor explicit foreign HLS URLs contact the unrelated origin");
|
||||
require(openNetwork(player, base, "/youtube-redirect/av.mp4", "", 4, false, true,
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAAA"),
|
||||
"YouTube selected source retains ordinary HTTP redirect behavior");
|
||||
require(player_play(player), "redirected YouTube fixture play");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .2 && s.has_video && s.has_audio; },
|
||||
"YouTube transport unchanged by resolved source restrictions");
|
||||
require(player_close(player), "close redirected YouTube fixture");
|
||||
closed(player);
|
||||
std::puts("PASS resolved HLS: AES-128/fMP4 audio+video decode/paused+playing seek/frame step/all resource credentials/token preservation/redirect and foreign-origin isolation/redacted errors/YouTube redirects unchanged");
|
||||
}
|
||||
|
||||
static void liveAndReplacement(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
require(openNetwork(player, base, "/media/av.mp4", "", 4, true), "live-flag network open");
|
||||
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
||||
@@ -336,6 +429,120 @@ static void prefixBound(PlayerState state, GateState gate, bool splitAudio = fal
|
||||
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
|
||||
}
|
||||
|
||||
static void reconnection(Player *player, const std::string &base) {
|
||||
for (int input : {0, 1, 2}) {
|
||||
const std::string token = "disconnect-" + std::to_string(input);
|
||||
const auto interrupted = "/disconnect/" + token + "/cache.mp4?after_seconds=8";
|
||||
require(openNetwork(player, base, input == 1 ? "/media/cache.mp4" : interrupted,
|
||||
input == 0 ? "" : input == 1 ? interrupted : "/media/cache.mp4", 120),
|
||||
"open media whose HTTP response will disconnect after a playable prefix");
|
||||
require(player_play(player), "play the source before its connection drops");
|
||||
auto before = until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s) &&
|
||||
gateState(base, token).entered;
|
||||
}, "playback and retained packets exist before the disconnect");
|
||||
if (input == 0) {
|
||||
require(player_pause(player), "pause while the original HTTP response is blocked");
|
||||
before = until(player, [](auto s) { return s.status == PLAYER_PAUSED; },
|
||||
"pause acknowledged before reconnect");
|
||||
}
|
||||
control(base, "/control/release/" + token);
|
||||
auto recovered = until(player, [&](auto s) {
|
||||
require(s.position + .001 >= before.position, "reconnection never resets the playhead");
|
||||
if (input == 0)
|
||||
require(s.status == PLAYER_PAUSED && std::abs(s.position - before.position) < .001,
|
||||
"reconnection does not resume paused playback");
|
||||
require(s.has_audio && s.has_video && s.can_seek && !s.seek_pending,
|
||||
"reconnection preserves ready media and its seekability");
|
||||
return buffered(s) && s.buffered_end - s.position >= 59;
|
||||
}, "same-source reconnect replenishes the minute reserve");
|
||||
require(recovered.buffered_start <= before.position,
|
||||
"reconnection retains the previously buffered timeline");
|
||||
seekCompleted(player, 6, input == 0 ? PLAYER_PAUSED : PLAYER_PLAYING);
|
||||
seekCompleted(player, 30, input == 0 ? PLAYER_PAUSED : PLAYER_PLAYING);
|
||||
require(player_play(player), "play across packets fetched after the disconnect");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
|
||||
"decoded audio/video continues beyond the original HTTP prefix");
|
||||
require(player_close(player), "close reconnected media");
|
||||
closed(player);
|
||||
}
|
||||
std::puts("PASS disconnected combined/secondary-audio/primary-video inputs: retained buffer/paused+playing intent/range resume/decoded continuation");
|
||||
}
|
||||
|
||||
static void socketTimeout(Player *player, const std::string &base) {
|
||||
const std::string token = "socket-timeout";
|
||||
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=8", "", 120),
|
||||
"open a source whose original socket never supplies the remaining body");
|
||||
require(player_play(player), "play the prefix before socket timeout");
|
||||
until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.position > .1 && gateState(base, token).entered;
|
||||
}, "playable prefix precedes timed-out fetch");
|
||||
require(player_pause(player), "pause before socket timeout");
|
||||
const auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; },
|
||||
"native pause before timeout recovery");
|
||||
until(player, [&](auto s) {
|
||||
require(s.status == PLAYER_PAUSED && std::abs(s.position - paused.position) < .001,
|
||||
"socket timeout recovery preserves paused intent and position");
|
||||
return buffered(s) && s.buffered_end - s.position >= 59;
|
||||
}, "socket timeout reconnects within the total I/O deadline");
|
||||
stillBlocked(base, token);
|
||||
require(player_play(player), "resume after socket timeout recovery");
|
||||
seekCompleted(player, 30, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
|
||||
"timeout-recovered media decodes beyond the withheld prefix");
|
||||
require(player_close(player), "close timeout-recovered source");
|
||||
closed(player);
|
||||
std::puts("PASS socket timeout: reconnect before operation deadline/retained buffer/paused intent/decoded continuation");
|
||||
}
|
||||
|
||||
static void failedReconnect(Player *player, const std::string &base, const std::string &token) {
|
||||
require(openNetwork(player, base, "/disconnect/" + token +
|
||||
"/cache.mp4?after_seconds=8&fail_reconnect=1", "", 120),
|
||||
"open a source with a playable prefix and persistently broken resumed responses");
|
||||
require(player_play(player), "play before persistent disconnect");
|
||||
until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.position > .1 && gateState(base, token).entered;
|
||||
}, "playback starts before persistent disconnect");
|
||||
control(base, "/control/release/" + token);
|
||||
until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.has_audio && s.has_video &&
|
||||
gateState(base, token).requests > 1;
|
||||
}, "transport is retrying while the retained prefix remains playable");
|
||||
}
|
||||
|
||||
static void retryFailures(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
failedReconnect(player, base, "retry-exhaustion");
|
||||
const auto failed = until(player, [](auto s) { return s.status == PLAYER_ERROR; },
|
||||
"persistent read failure exhausts its bounded retries", true);
|
||||
require(failed.error[0] && !failed.has_video && !failed.has_audio && !failed.buffering &&
|
||||
!failed.can_seek && !failed.seek_pending,
|
||||
"exhausted retries surface an error instead of falsely completing media or hanging");
|
||||
for (int action : {0, 1, 2}) {
|
||||
failedReconnect(player, base, "retry-cancellation-" + std::to_string(action));
|
||||
if (action == 0) {
|
||||
seekCompleted(player, 30, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
|
||||
"uncached seek escapes the failed reconnect offset");
|
||||
} else if (action == 2) {
|
||||
require(player_open(player, (fixtures + "/audio.wav").c_str()),
|
||||
"local replacement interrupts the retry");
|
||||
const auto replacement = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PAUSED && s.has_audio && !s.has_video;
|
||||
}, "replacement is not blocked by retry backoff");
|
||||
require(replacement.position == 0 && !replacement.seek_pending && !replacement.buffering,
|
||||
"stale retry does not overwrite replacement state");
|
||||
}
|
||||
require(player_close(player), "close interrupts retries and recovered media");
|
||||
closed(player);
|
||||
}
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> victim(player_create(), player_destroy);
|
||||
require(bool(victim), "create a player for destruction during retry");
|
||||
require(player_set_volume(victim.get(), 0), "mute destruction scenario");
|
||||
failedReconnect(victim.get(), base, "retry-destruction");
|
||||
victim.reset(); // The process watchdog catches a join stuck in retry backoff.
|
||||
std::puts("PASS exhausted reconnects: visible failure/uncached seek/close/replacement/destruction cancellation");
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -606,6 +813,18 @@ static void reporting(const std::string &base, const std::string &fixtures) {
|
||||
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
|
||||
[rows[0][@"completion_eligible"] boolValue],
|
||||
"initial report is restored native position, not pre-resume zero");
|
||||
PlayerState receiver = player_get_state(player.get());
|
||||
receiver.position = 114;
|
||||
receiver.status = PLAYER_PAUSED;
|
||||
require(player_report_remote_state(player.get(), &receiver), "receiver-confirmed progress accepted");
|
||||
require(player_close(player.get()), "close immediately after receiver progress");
|
||||
until(player.get(), [&](auto state) {
|
||||
auto current = progressRecords(path);
|
||||
return state.status == PLAYER_EMPTY && [current.lastObject[@"event"] isEqualToString:@"closed"] &&
|
||||
[current.lastObject[@"position_seconds"] doubleValue] == 114;
|
||||
}, "closing drains final Cast position instead of abandoned decoder position");
|
||||
require([progressRecords(path).lastObject[@"completion_eligible"] boolValue],
|
||||
"receiver-confirmed video remains completion eligible");
|
||||
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);
|
||||
@@ -684,6 +903,7 @@ int main(int argc, char **argv) {
|
||||
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");
|
||||
av_log_set_level(AV_LOG_TRACE); // Diagnostics must be safe even at maximal FFmpeg verbosity.
|
||||
[NSApplication sharedApplication];
|
||||
reporting(base, fixtures);
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
@@ -696,12 +916,16 @@ int main(int argc, char **argv) {
|
||||
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
window.contentView = view;
|
||||
[window orderBack:nil];
|
||||
reconnection(player.get(), base);
|
||||
socketTimeout(player.get(), base);
|
||||
retryFailures(player.get(), base, fixtures);
|
||||
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);
|
||||
resolvedHLS(player.get(), view, base);
|
||||
liveAndReplacement(player.get(), base, fixtures);
|
||||
cachedWindow(player.get(), base);
|
||||
bufferCoverage(player.get(), base);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct PlayerCommandChecks {
|
||||
@@ -72,6 +73,64 @@ struct PlayerCommandChecks {
|
||||
require(try opened(["--", "--progress-file"]) == reportFlagFile,
|
||||
"escaped reporting option remains a filename")
|
||||
|
||||
let sourceFile = directory.appendingPathComponent("source with spaces.json")
|
||||
let sourceURL = "https://cdn.example/video/master.m3u8?token=secret%2Bvalue"
|
||||
var source: [String: Any] = [
|
||||
"version": 1, "url": sourceURL, "headers": ["Authorization": "Bearer secret "],
|
||||
"identity": ["provider": "miruro", "media_id": "opaque_A-1", "episode": 12],
|
||||
"subtitles": [["url": "https://cdn.example/sub.vtt?token=secret", "format": "vtt",
|
||||
"language": "en", "label": "English", "default": true, "headers": ["X-Token": "secret"]]]
|
||||
]
|
||||
func saveSource() throws {
|
||||
try JSONSerialization.data(withJSONObject: source).write(to: sourceFile)
|
||||
}
|
||||
try saveSource()
|
||||
let resolved = try ResolvedSource.load(from: sourceFile)
|
||||
require(resolved.url == sourceURL && resolved.headers == "Authorization: Bearer secret \r\n",
|
||||
"resolved URLs and header values remain byte-for-byte intact")
|
||||
require(resolved.identity == "miruro:opaque_A-1:12" && resolved.title == "Miruro episode 12",
|
||||
"stable identity excludes CDN tokens and omitted title has a useful fallback")
|
||||
require(resolved.subtitles.count == 1 && resolved.subtitles[0].isDefault &&
|
||||
resolved.subtitles[0].headers == "X-Token: secret\r\n", "subtitle metadata retains independent headers")
|
||||
guard case .launchSource(let delivered, let independent, let bypass, let output) =
|
||||
try PlayerCommand.parse(["--new-instance", "--no-config", "--source-file", sourceFile.lastPathComponent,
|
||||
"--progress-file", report.lastPathComponent], directory: directory) else {
|
||||
fatalError("FAIL: expected resolved source launch")
|
||||
}
|
||||
require(delivered == sourceFile && independent && bypass && output == report,
|
||||
"CLI forwards only an absolute source-file path with existing launch options")
|
||||
for arguments in [["--source-file", sourceFile.path], ["--new-instance", "--source-file"],
|
||||
["--new-instance", "--source-file", sourceFile.path, movie.path]] {
|
||||
do { _ = try PlayerCommand.parse(arguments, directory: directory); fatalError("FAIL: invalid source launch accepted") }
|
||||
catch {}
|
||||
}
|
||||
let original = source
|
||||
let invalidChanges: [[String: Any]] = [
|
||||
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
||||
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
|
||||
["headers": ["X-Token": "secret\r\nInjected: yes"]], ["headers": ["X-Token": "secret\t"]],
|
||||
["headers": ["Bad Name": "secret"]], ["headers": ["X-Token": "one", "x-token": "two"]],
|
||||
["identity": ["provider": "miruro", "media_id": "bad:id", "episode": 1]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
|
||||
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
|
||||
]
|
||||
for change in invalidChanges {
|
||||
source = original.merging(change) { _, replacement in replacement }
|
||||
try saveSource()
|
||||
do { _ = try ResolvedSource.load(from: sourceFile); fatalError("FAIL: invalid source accepted") }
|
||||
catch { require(!error.localizedDescription.contains("secret"), "validation never reveals credentials") }
|
||||
}
|
||||
try Data(repeating: 32, count: 1_048_577).write(to: sourceFile)
|
||||
do { _ = try ResolvedSource.load(from: sourceFile); fatalError("FAIL: oversized source accepted") }
|
||||
catch {}
|
||||
let fifo = directory.appendingPathComponent("source.fifo")
|
||||
require(mkfifo(fifo.path, 0o600) == 0, "create unread FIFO source boundary")
|
||||
do {
|
||||
_ = try PlayerCommand.parse(["--new-instance", "--source-file", fifo.path], directory: directory)
|
||||
fatalError("FAIL: FIFO source accepted")
|
||||
} catch {}
|
||||
|
||||
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"],
|
||||
|
||||
@@ -248,6 +248,47 @@ int main(int argc, char **argv) {
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
|
||||
|
||||
require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") &&
|
||||
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
|
||||
for (const char *invalid : {"miruro::1", "miruro:a:0", "miruro:a:01", "miruro:a:-1",
|
||||
"miruro:a:1.5", "miruro:a:2147483648", "miruro:a:1?token=secret",
|
||||
"miruro:a:b:1", "miruro:a:1\n", "miruro:bad/id:1", "miruro:é:1"})
|
||||
require(!ProgressReporter::validMediaIdentity(invalid), "ambiguous or token-bearing identities rejected");
|
||||
const auto miruroPath = root + "/miruro.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(miruroPath, [] { return 0.; });
|
||||
reporter.begin("miruro:opaque_A-1:12");
|
||||
reporter.observe(sample(PLAYER_PLAYING, 95));
|
||||
reporter.begin("miruro:opaque_A-1:13");
|
||||
reporter.observe(sample(PLAYER_ENDED, 100));
|
||||
reporter.finish();
|
||||
require(reporter.error().empty(), "Miruro progress drains successfully");
|
||||
}
|
||||
NSString *miruroText = [NSString stringWithContentsOfFile:nativePath(miruroPath)
|
||||
encoding:NSUTF8StringEncoding error:nullptr];
|
||||
NSArray<NSString *> *miruroLines = [miruroText componentsSeparatedByString:@"\n"];
|
||||
require(miruroLines.count == 5, "Miruro replacement emits progress, close, ended, close");
|
||||
NSSet *miruroKeys = [NSSet setWithArray:@[@"version", @"event", @"media_identity",
|
||||
@"position_seconds", @"duration_seconds", @"completion_eligible"]];
|
||||
for (NSUInteger i = 0; i < 4; ++i) {
|
||||
NSDictionary *row = [NSJSONSerialization JSONObjectWithData:[miruroLines[i]
|
||||
dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nullptr];
|
||||
require([row isKindOfClass:[NSDictionary class]] &&
|
||||
[[NSSet setWithArray:row.allKeys] isEqual:miruroKeys] &&
|
||||
[row[@"version"] intValue] == 2, "Miruro schema is v2 without media_url");
|
||||
NSDictionary *identity = row[@"media_identity"];
|
||||
require([identity isKindOfClass:[NSDictionary class]] &&
|
||||
[[NSSet setWithArray:identity.allKeys] isEqual:
|
||||
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
|
||||
[identity[@"provider"] isEqual:@"miruro"] &&
|
||||
[identity[@"media_id"] isEqual:@"opaque_A-1"] &&
|
||||
[identity[@"episode"] intValue] == (i < 2 ? 12 : 13),
|
||||
"structured stable episode identity contains no CDN token");
|
||||
require([row[@"event"] isEqual:(i == 0 ? @"progress" : i == 2 ? @"ended" : @"closed")] &&
|
||||
[row[@"position_seconds"] doubleValue] == (i < 2 ? 95 : 100) &&
|
||||
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
|
||||
}
|
||||
|
||||
const auto clockFailurePath = root + "/clock-failure.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Local-only resolved HLS/VTT + native model smoke; no live requests."""
|
||||
import http.server
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
binary = str(Path(sys.argv[1]).resolve())
|
||||
fixtures = Path(sys.argv[2]).resolve()
|
||||
seen = []
|
||||
errors = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path).path
|
||||
seen.append(path)
|
||||
expected = "subtitle-private" if path == "/english.jpeg" else "stream-private"
|
||||
if self.headers.get("X-Resource") != expected:
|
||||
errors.append("wrong resource headers: " + path)
|
||||
self.send_error(403)
|
||||
return
|
||||
if path == "/master.m3u8":
|
||||
if urlsplit(self.path).query != "token=fixture":
|
||||
errors.append("master token lost")
|
||||
data = b'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\nvariant.m3u8?token=variant\n'
|
||||
elif path == "/variant.m3u8":
|
||||
if urlsplit(self.path).query != "token=variant":
|
||||
errors.append("variant token lost")
|
||||
data = (fixtures / "resolved-vod.m3u8").read_bytes()
|
||||
elif path == "/english.jpeg":
|
||||
data = b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n'
|
||||
elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
|
||||
data = (fixtures / path[1:]).read_bytes()
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Content-Type", "text/vtt" if path.endswith(".jpeg") else "application/octet-stream")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(fixtures / "av.mp4"),
|
||||
"-c:v", "libx264", "-g", "24", "-keyint_min", "24", "-sc_threshold", "0", "-c:a", "aac",
|
||||
"-hls_time", "1", "-hls_playlist_type", "vod", "-hls_segment_filename", str(fixtures / "resolved-vod-%d.ts"),
|
||||
str(fixtures / "resolved-vod.m3u8")], check=True)
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-resolved-") as temp:
|
||||
source = Path(temp) / "source.json"
|
||||
progress = Path(temp) / "progress.jsonl"
|
||||
source.write_text(json.dumps({"version": 1, "url": base + "/master.m3u8?token=fixture",
|
||||
"headers": {"X-Resource": "stream-private"}, "identity": {"provider": "miruro", "media_id": "fixture-ID", "episode": 1},
|
||||
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
||||
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
||||
source.chmod(0o600)
|
||||
subprocess.run([binary, str(source), str(progress)], check=True, timeout=100)
|
||||
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
||||
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
||||
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
||||
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
|
||||
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
||||
assert not errors, errors
|
||||
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
|
||||
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
||||
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
@@ -0,0 +1,66 @@
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct ResolvedPlaybackCheck {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if model.state.status == PLAYER_ERROR || model.commandError != nil {
|
||||
fatalError("FAIL: " + (model.errorText ?? "Playback failed"))
|
||||
}
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
static func main() async {
|
||||
do { try await run() }
|
||||
catch { fatalError("FAIL: " + error.localizedDescription) }
|
||||
}
|
||||
|
||||
static func run() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let file = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
let suite = "terebi.resolved-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil)
|
||||
defer { model.shutdown() }
|
||||
model.openResolvedSource(file)
|
||||
until("video/audio decode and advancing playback", model: model) {
|
||||
model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio && model.state.position > 0.2
|
||||
}
|
||||
print("PASS: resolved HLS decodes video/audio and advances")
|
||||
fflush(stdout)
|
||||
require(model.setPlayback(playing: false), "pause accepted")
|
||||
until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.state.can_seek && model.state.duration > 0, "seekable measured duration")
|
||||
guard let index = source.subtitles.firstIndex(where: \.isDefault) else {
|
||||
fatalError("FAIL: smoke requires a default subtitle")
|
||||
}
|
||||
let track = source.subtitles[index]
|
||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||
let playableCues = document.cues.filter { !$0.text.isEmpty && $0.start + 0.1 < model.state.duration }
|
||||
require(playableCues.count >= 2, "smoke requires two visible subtitle cues")
|
||||
let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
|
||||
require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue")
|
||||
require(model.seekRelative(target - model.state.position), "seek accepted")
|
||||
until("seek acknowledged and subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - target) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position) && model.subtitleError == nil
|
||||
}
|
||||
require(model.selectedSubtitle == index && model.subtitleTracks[index].label == track.label, "default selection and label retained")
|
||||
let backward = playableCues[0].start + min(0.1, (playableCues[0].end - playableCues[0].start) / 2)
|
||||
require(model.seekRelative(backward - model.state.position), "backward seek accepted")
|
||||
until("backward seek subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - backward) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position)
|
||||
}
|
||||
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing")
|
||||
}
|
||||
}
|
||||
+146
-5
@@ -7,6 +7,7 @@ import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -44,6 +45,8 @@ class StreamingServer:
|
||||
"network-vod.m3u8": "combined",
|
||||
**{f"network-vod-{index}.ts": "combined" for index in range(4)},
|
||||
}
|
||||
resolved_token = "token=part%2Fone+two%26tail"
|
||||
resolved_secret = "resolved-header-secret"
|
||||
|
||||
def __init__(self, fixtures: str | Path):
|
||||
self.fixtures = Path(fixtures).resolve()
|
||||
@@ -51,6 +54,8 @@ class StreamingServer:
|
||||
self.gates: dict[str, _Gate] = {}
|
||||
self.stats: dict[str, dict[str, int]] = {}
|
||||
self.prefixes: dict[tuple[str, int], dict[str, float]] = {}
|
||||
self.resolved_stats: dict[str, dict[str, dict[str, int]]] = {}
|
||||
self.sink_stats = {"requests": 0, "credentials": 0}
|
||||
self.stopping = threading.Event()
|
||||
owner = self
|
||||
|
||||
@@ -70,6 +75,19 @@ class StreamingServer:
|
||||
# Cancellation can close a body read or an idle keep-alive connection.
|
||||
self.close_connection = True
|
||||
|
||||
class SinkHandler(Handler):
|
||||
def do_GET(self):
|
||||
with owner.lock:
|
||||
owner.sink_stats["requests"] += 1
|
||||
owner.sink_stats["credentials"] += int(
|
||||
self.headers.get("Authorization") is not None or
|
||||
self.headers.get("X-Terebi-Fixture") is not None)
|
||||
owner._reply(self, 403, b"Unrelated origin")
|
||||
|
||||
self.sink = ThreadingHTTPServer(("127.0.0.1", 0), SinkHandler)
|
||||
self.sink.daemon_threads = True
|
||||
self.sink_thread = threading.Thread(target=self.sink.serve_forever, daemon=True)
|
||||
self.sink_url = f"http://127.0.0.1:{self.sink.server_port}"
|
||||
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.http.daemon_threads = True
|
||||
self.thread = threading.Thread(target=self.http.serve_forever, daemon=True)
|
||||
@@ -77,6 +95,7 @@ class StreamingServer:
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
self.sink_thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
@@ -87,6 +106,9 @@ class StreamingServer:
|
||||
self.http.shutdown()
|
||||
self.http.server_close()
|
||||
self.thread.join()
|
||||
self.sink.shutdown()
|
||||
self.sink.server_close()
|
||||
self.sink_thread.join()
|
||||
|
||||
@staticmethod
|
||||
def _reply(request, status: int, body: bytes, content_type="application/json"):
|
||||
@@ -155,9 +177,92 @@ class StreamingServer:
|
||||
if "pos" in packet and "pts_time" in packet
|
||||
and float(packet["pts_time"]) - origin >= seconds)
|
||||
|
||||
def _resolved(self, request, parsed, parts):
|
||||
scenario = parts[1]
|
||||
relative = "/".join(parts[2:])
|
||||
kind = ("master" if relative == "master.m3u8" else
|
||||
"variant" if relative == "nested/media.m3u8" else
|
||||
"key" if relative == "keys/key.bin" else
|
||||
"init" if relative in ("init.mp4", "init.jpeg") else "segment")
|
||||
with self.lock:
|
||||
stats = self.resolved_stats.setdefault(scenario, {}).setdefault(
|
||||
kind, {"requests": 0, "denied": 0, "redirects": 0})
|
||||
stats["requests"] += 1
|
||||
denied = (parsed.query != self.resolved_token or
|
||||
request.headers.get("X-Terebi-Fixture") != "combined" or
|
||||
request.headers.get("Authorization") != "Bearer " + self.resolved_secret)
|
||||
stats["denied"] += int(denied)
|
||||
if denied:
|
||||
self._reply(request, 403, b"Required source credentials or complete token missing")
|
||||
return
|
||||
if scenario == "redirect-" + kind or scenario == "redirect-same-master" and kind == "master":
|
||||
with self.lock:
|
||||
stats["redirects"] += 1
|
||||
request.send_response(302)
|
||||
request.send_header("Location", self.base_url + "/media/network-vod.m3u8"
|
||||
if scenario == "redirect-same-master" else self.sink_url + "/leak")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
fragmented = scenario in ("fmp4", "image-fmp4", "redirect-init", "external-init")
|
||||
image_named = scenario.startswith("image-")
|
||||
directory = self.fixtures / "resolved-hls" / ("fmp4" if fragmented else "encrypted")
|
||||
|
||||
def uri(path, resource_kind):
|
||||
if image_named and resource_kind in ("segment", "init"):
|
||||
path = path.rsplit(".", 1)[0] + (".jpeg" if fragmented else ".jpg")
|
||||
address = path + "?" + self.resolved_token
|
||||
if scenario == "external-" + resource_kind:
|
||||
return self.sink_url + "/leak/" + address
|
||||
return address
|
||||
|
||||
if kind == "master":
|
||||
body = ("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\n" +
|
||||
uri("nested/media.m3u8", "variant") + "\n").encode()
|
||||
elif kind == "variant":
|
||||
playlist = (directory / "media.m3u8").read_text()
|
||||
playlist = re.sub(r'URI="[^"]+"', lambda match: 'URI="' + uri(
|
||||
"../init.mp4" if fragmented else "../keys/key.bin",
|
||||
"init" if fragmented else "key") + '"', playlist)
|
||||
playlist = re.sub(r"(?m)^(seg-[^\n]+)$",
|
||||
lambda match: uri("../segments/" + match[1], "segment"), playlist)
|
||||
body = playlist.encode()
|
||||
else:
|
||||
name = relative.removeprefix("segments/").removeprefix("keys/")
|
||||
# Resource routing is deliberately finite; never serve arbitrary paths.
|
||||
if not re.fullmatch(r"key\.bin|init\.(?:mp4|jpeg)|seg-\d+\.(?:ts|m4s|jpg|jpeg)", name):
|
||||
self._reply(request, 404, b"Unknown HLS resource")
|
||||
return
|
||||
if image_named:
|
||||
name = (name.rsplit(".", 1)[0] + ".mp4" if kind == "init" else
|
||||
name.rsplit(".", 1)[0] + (".m4s" if fragmented else ".ts")
|
||||
if kind == "segment" else name)
|
||||
body = (directory / name).read_bytes()
|
||||
self._reply(request, 200, body, "application/vnd.apple.mpegurl"
|
||||
if kind in ("master", "variant") else
|
||||
"image/jpeg" if image_named and kind in ("segment", "init") else "application/octet-stream")
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
parts = unquote(parsed.path).strip("/").split("/")
|
||||
if parts == ["control", "resolved-stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps({"sources": self.resolved_stats, "sink": self.sink_stats}).encode()
|
||||
self._reply(request, 200, body)
|
||||
return
|
||||
if len(parts) >= 3 and parts[0] == "resolved":
|
||||
self._resolved(request, parsed, parts)
|
||||
return
|
||||
if parts == ["youtube-redirect", "av.mp4"]:
|
||||
request.send_response(302)
|
||||
request.send_header("Location", self.base_url + "/media/av.mp4")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
if parts == ["control", "stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps(self.stats).encode()
|
||||
@@ -181,7 +286,7 @@ class StreamingServer:
|
||||
|
||||
mode = parts[0]
|
||||
token = None
|
||||
if len(parts) == 3 and mode in ("stall", "stall-open", "chunked-media"):
|
||||
if len(parts) == 3 and mode in ("stall", "stall-open", "chunked-media", "disconnect"):
|
||||
token, name = parts[1:]
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", token):
|
||||
self._reply(request, 400, b"Invalid gate token", "text/plain")
|
||||
@@ -229,14 +334,14 @@ class StreamingServer:
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
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)
|
||||
if mode in ("stall", "chunked-media", "disconnect") and "after_seconds" in query else
|
||||
int(query.get("after", ["131072"])[0]) if mode in ("stall", "chunked-media", "disconnect") 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 in ("stall", "chunked-media") else {}
|
||||
prefix = self._prefix_ends(name, gate_at) if mode in ("stall", "chunked-media", "disconnect") else {}
|
||||
self.gates[token] = _Gate(size, prefix)
|
||||
gate = self.gates[token]
|
||||
gate.requests += 1
|
||||
@@ -266,6 +371,9 @@ class StreamingServer:
|
||||
request.end_headers()
|
||||
request.wfile.flush()
|
||||
request.close_connection = not persistent
|
||||
if (mode == "disconnect" and not should_gate and start <= gate_at
|
||||
and query.get("fail_reconnect") == ["1"]):
|
||||
return # Every resumed response at the broken offset is also truncated.
|
||||
cursor = start
|
||||
with path.open("rb") as media:
|
||||
media.seek(start)
|
||||
@@ -280,6 +388,8 @@ class StreamingServer:
|
||||
with self.lock:
|
||||
gate.waiting -= 1
|
||||
should_gate = False
|
||||
if mode == "disconnect":
|
||||
return # Advertise the complete range, then cut its body short.
|
||||
amount = min(16384, end - cursor + 1)
|
||||
if should_gate:
|
||||
amount = min(amount, gate_at - cursor)
|
||||
@@ -295,6 +405,27 @@ class StreamingServer:
|
||||
gate.bytes += len(chunk)
|
||||
|
||||
|
||||
def resolved_fixtures(fixtures: Path):
|
||||
"""Generate deterministic AES-128 TS and fMP4 fixtures without external services."""
|
||||
for fragmented in (False, True):
|
||||
directory = fixtures.resolve() / "resolved-hls" / ("fmp4" if fragmented else "encrypted")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", str(fixtures.resolve() / "av.mp4"),
|
||||
"-c:v", "libx264", "-g", "24", "-keyint_min", "24", "-sc_threshold", "0",
|
||||
"-c:a", "aac", "-hls_time", "1", "-hls_playlist_type", "vod"]
|
||||
if fragmented:
|
||||
command += ["-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4"]
|
||||
else:
|
||||
(directory / "key.bin").write_bytes(bytes(range(16)))
|
||||
(directory / "keyinfo").write_text("key.bin\n" + str(directory / "key.bin") +
|
||||
"\n000102030405060708090a0b0c0d0e0f\n")
|
||||
command += ["-hls_key_info_file", str(directory / "keyinfo")]
|
||||
command += ["-hls_segment_filename", str(directory / ("seg-%d.m4s" if fragmented else "seg-%d.ts")),
|
||||
str(directory / "media.m3u8")]
|
||||
subprocess.run(command, check=True, timeout=30)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
@@ -305,12 +436,22 @@ def main():
|
||||
missing = [name for name in required if not (args.fixtures / name).is_file()]
|
||||
if missing:
|
||||
parser.error("Missing streaming fixtures: " + ", ".join(missing))
|
||||
resolved_fixtures(args.fixtures)
|
||||
with StreamingServer(args.fixtures) as server:
|
||||
try:
|
||||
result = subprocess.run([str(args.binary.resolve()), server.base_url,
|
||||
str(args.fixtures.resolve())], timeout=180, check=False)
|
||||
str(args.fixtures.resolve())], timeout=180, check=False,
|
||||
capture_output=True)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit("FAIL: native network check exceeded its infrastructure deadline")
|
||||
private = (StreamingServer.resolved_token.encode(), StreamingServer.resolved_secret.encode(),
|
||||
b"Bearer ", server.base_url.encode(), server.sink_url.encode())
|
||||
if any(secret in result.stdout or secret in result.stderr for secret in private):
|
||||
raise SystemExit("FAIL: native diagnostics exposed source URL or credentials")
|
||||
print(result.stdout.decode(), end="")
|
||||
print(result.stderr.decode(), end="", file=sys.stderr)
|
||||
if server.sink_stats["requests"] or server.sink_stats["credentials"]:
|
||||
raise SystemExit("FAIL: resolved playback contacted an unrelated origin")
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,40 @@ struct SystemPlaybackChecks {
|
||||
require(condition(), message)
|
||||
}
|
||||
|
||||
static func checkCompactProgress() {
|
||||
let times: [(Double, Double, String)] = [
|
||||
(12.9, 45.9, "12 / 45 s"),
|
||||
(59, 59.999, "59 / 59 s"),
|
||||
(0, 60, "00:00 / 01:00 m"),
|
||||
(312, 2528, "05:12 / 42:08 m"),
|
||||
(3599, 3599.999, "59:59 / 59:59 m"),
|
||||
(59, 3600, "00:00 / 01:00 h"),
|
||||
(4980, 7800, "01:23 / 02:10 h"),
|
||||
(-5, 45, "0 / 45 s"),
|
||||
(80, 45, "45 / 45 s"),
|
||||
]
|
||||
for (position, duration, expected) in times {
|
||||
require(compactProgressText(position: position, duration: duration, format: .time) == expected,
|
||||
"compact time uses total-duration units, truncation and bounded progress: \(expected)")
|
||||
}
|
||||
for (position, expected) in [(59.9, "59"), (100.0, "100"), (105.0, "100"), (-1.0, "0")] {
|
||||
require(compactProgressText(position: position, duration: 100, format: .percentage) == expected,
|
||||
"compact percentage is a bounded integer: \(expected)")
|
||||
}
|
||||
for duration in [0.0, -1.0, .nan, .infinity] {
|
||||
require(compactProgressText(position: 10, duration: duration, format: .time) == nil &&
|
||||
compactProgressText(position: 10, duration: duration, format: .percentage) == nil,
|
||||
"unknown/invalid duration has no compact progress")
|
||||
}
|
||||
require(compactProgressText(position: .nan, duration: 100, format: .time) == nil &&
|
||||
compactProgressText(position: .infinity, duration: 100, format: .percentage) == nil,
|
||||
"nonfinite positions never appear as compact progress")
|
||||
require(compactProgressText(position: 59, duration: 100, format: .none) == nil,
|
||||
"disabled compact progress leaves only the title")
|
||||
}
|
||||
|
||||
static func main() {
|
||||
checkCompactProgress()
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
||||
@@ -48,13 +81,55 @@ struct SystemPlaybackChecks {
|
||||
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||
let center = MPNowPlayingInfoCenter.default()
|
||||
let commands = MPRemoteCommandCenter.shared()
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
model.attachWindow(window)
|
||||
model.open(directory.appendingPathComponent("anamorphic.mp4"))
|
||||
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
|
||||
"compact entry is available before native media opening acknowledges")
|
||||
until("decoded anamorphic metadata replaces provisional compact geometry") {
|
||||
model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
|
||||
abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01
|
||||
}
|
||||
model.closeFile()
|
||||
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
||||
"closing loading/loaded media restores immediately")
|
||||
until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY }
|
||||
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
|
||||
|
||||
model.open(directory.appendingPathComponent("av.mp4"))
|
||||
if !NSWorkspace.shared.isVoiceOverEnabled {
|
||||
model.setPointerInsideWindow(false)
|
||||
require(!model.controlsVisible, "pointer exit hides playback chrome while media is loading")
|
||||
model.setPointerInsideWindow(true)
|
||||
}
|
||||
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
|
||||
require(center.playbackState == .playing, "playing publishes macOS playback ownership")
|
||||
require(model.setPlayback(playing: false), "explicit pause accepted")
|
||||
until("pause acknowledged") { model.state.status == PLAYER_PAUSED }
|
||||
let pausedPosition = model.state.position
|
||||
if !NSWorkspace.shared.isVoiceOverEnabled {
|
||||
model.setControlsHovered(true)
|
||||
model.setTitleHovered(true)
|
||||
model.setPointerInsideWindow(false)
|
||||
require(!model.controlsVisible,
|
||||
"pointer exit hides paused playback chrome despite stale hover and inactive window")
|
||||
model.setControlsHovered(false)
|
||||
model.setTitleHovered(false)
|
||||
model.updateWindowState()
|
||||
require(!model.controlsVisible,
|
||||
"late hover/focus updates cannot reshow chrome while the pointer is outside")
|
||||
model.revealControls(forKeyboardNavigation: true)
|
||||
model.revealControls()
|
||||
require(model.controlsVisible, "Tab navigation can reveal controls without moving the pointer")
|
||||
model.setPointerInsideWindow(false)
|
||||
require(!model.controlsVisible, "another pointer exit dismisses keyboard-revealed controls")
|
||||
model.setPointerInsideWindow(true)
|
||||
require(model.controlsVisible, "pointer reentry reveals paused playback chrome")
|
||||
}
|
||||
require(model.setPlayback(playing: false), "repeated pause accepted")
|
||||
until("idempotent pause does not restart playback") {
|
||||
model.state.status == PLAYER_PAUSED && center.playbackState == .paused
|
||||
@@ -108,6 +183,21 @@ struct SystemPlaybackChecks {
|
||||
require(model.setPlayback(playing: true), "repeated play accepted")
|
||||
until("repeated play keeps playing") { model.state.status == PLAYER_PLAYING && model.state.position > 0.2 }
|
||||
|
||||
if !NSWorkspace.shared.isVoiceOverEnabled {
|
||||
require(model.perform(.enterCompact), "compact playback is available for hidden-keyboard seeking")
|
||||
until("compact entry completes before hiding controls") { !model.compactWindow.isTransitioning }
|
||||
model.hideControls(hideCursor: false)
|
||||
require(!model.controlsVisible, "playing chrome can be hidden before a keyboard seek")
|
||||
require(model.handleKey(try! KeyChord("left")), "keyboard seek is accepted while chrome is hidden")
|
||||
require(!model.controlsVisible, "keyboard seek must not flash playback chrome")
|
||||
until("hidden keyboard seek acknowledges the start boundary") {
|
||||
require(!model.controlsVisible, "seek acknowledgement must preserve hidden chrome")
|
||||
return !model.state.seek_pending && model.state.position < 0.15
|
||||
}
|
||||
require(model.perform(.exitCompact), "keyboard seek check restores normal mode")
|
||||
until("compact exit completes") { !model.compactWindow.isTransitioning }
|
||||
}
|
||||
|
||||
// Closing must hide the last polled retained range before the next state poll.
|
||||
var retainedSnapshot = model.state
|
||||
retainedSnapshot.buffered_start = retainedSnapshot.position
|
||||
|
||||
@@ -12,10 +12,10 @@ 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)/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
|
||||
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.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 Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift
|
||||
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
|
||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.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
|
||||
@@ -27,7 +27,7 @@ CLI := $(APP)/Contents/MacOS/terebi-cli
|
||||
BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
|
||||
SIGNING_IDENTITY ?= -
|
||||
|
||||
.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 check-bundle
|
||||
.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 check-bundle check-subtitles check-resolved check-cast
|
||||
all: $(BUILD_DIR)/terebi
|
||||
|
||||
$(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json
|
||||
@@ -62,12 +62,15 @@ $(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 $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
$(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(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 $(NATIVE_STAMP) | $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/cast_media.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/network_policy.hpp Sources/Core/network_input.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
|
||||
|
||||
@@ -102,7 +105,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-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle
|
||||
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 check-bundle check-subtitles check-resolved check-cast
|
||||
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
check-fixtures: | check-dependencies
|
||||
@@ -129,11 +132,13 @@ check-audio: $(BUILD_DIR)/audio-continuity-check
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=24:duration=2 -i $(BUILD_DIR)/fixtures/tone480.wav -c:v mpeg4 -g 48 -bf 2 -c:a copy $(BUILD_DIR)/fixtures/tone-step.mov
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -c:a copy $(BUILD_DIR)/fixtures/tone441.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -c:a aac $(BUILD_DIR)/fixtures/tone-aac.m4a
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone480.wav -af "asetnsamples=n=1024:p=0,asetpts='round(PTS*TB*1000)/1000/TB'" -c:a aac -f mpegts $(BUILD_DIR)/fixtures/tone-jitter.ts
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -af "asetpts='PTS+gte(T,1)*0.25/TB'" -c:a pcm_s16le $(BUILD_DIR)/fixtures/tone-gap.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone441.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone480.wav -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone480.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.mka -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone441.mka.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone-aac.m4a -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone-aac.m4a.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone-jitter.ts -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone-jitter.ts.f32
|
||||
$(BUILD_DIR)/audio-continuity-check $(BUILD_DIR)/fixtures
|
||||
|
||||
$(BUILD_DIR)/compact-window-check: Checks/compact-window.swift Sources/App/CompactWindow.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
@@ -191,12 +196,25 @@ $(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core
|
||||
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 $@
|
||||
$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.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 Sources/App/ResolvedSource.swift -framework Foundation -o $@
|
||||
|
||||
check-cli: $(BUILD_DIR)/player-command-check
|
||||
$(BUILD_DIR)/player-command-check
|
||||
|
||||
$(BUILD_DIR)/external-subtitles-check: Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift -framework Foundation -o $@
|
||||
|
||||
check-subtitles: $(BUILD_DIR)/external-subtitles-check
|
||||
$(BUILD_DIR)/external-subtitles-check
|
||||
|
||||
$(BUILD_DIR)/resolved-playback-check: Checks/resolved-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-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
|
||||
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
|
||||
|
||||
@@ -209,6 +227,23 @@ $(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES
|
||||
check-media: $(BUILD_DIR)/system-playback-check check-fixtures
|
||||
$(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
$(BUILD_DIR)/cast-session-check: Checks/cast-session.swift Sources/App/CastSession.swift Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DCAST_SESSION_CHECKS -O $(DEBUG_FLAGS) Checks/cast-session.swift Sources/App/CastSession.swift -framework Foundation -framework Network -framework Security -o $@
|
||||
|
||||
$(BUILD_DIR)/cast_media-check.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/network_policy.hpp Sources/Core/network_input.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -DCAST_MEDIA_CHECK -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/cast-media-check: Checks/cast-media.cpp $(BUILD_DIR)/cast_media-check.o Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -ISources/Core -DCAST_MEDIA_CHECK $< $(BUILD_DIR)/cast_media-check.o $(FF_LIBS) -o $@
|
||||
|
||||
$(BUILD_DIR)/cast-playback-check: Checks/cast-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-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures
|
||||
$(BUILD_DIR)/cast-session-check
|
||||
$(BUILD_DIR)/cast-media-check --live
|
||||
$(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4
|
||||
|
||||
$(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 $@
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# terebi
|
||||
|
||||
A personal native macOS player whose playback code is small enough to own and modify. FFmpeg reads and decodes media; our C++ worker owns playback. SwiftUI owns the controls, Core Audio owns audio output, and Metal presents video.
|
||||
A personal native macOS player whose playback code is small enough to own and modify. FFmpeg reads and decodes media; our C++ worker owns local playback. SwiftUI owns the controls, Core Audio owns local audio output, and Metal presents local video. Chromecast owns remote playback.
|
||||
|
||||
## Build and run
|
||||
|
||||
@@ -17,7 +17,7 @@ make run
|
||||
|
||||
The developer app is `.build/terebi.app`; release builds produce `.build/release/terebi.app`. Both are self-contained for macOS 15+ on the build machine's CPU architecture. Copy the whole app to another compatible Mac; no Homebrew, FFmpeg installation, Python installation, yt-dlp installation, or shell PATH setup is needed to run it. Build natively on Apple Silicon or Intel; cross-architecture and Rosetta builds are rejected, and these are not universal or cross-platform binaries.
|
||||
|
||||
`Dependencies.lock.json` pins SHA256-verified FFmpeg 9.0.1, dav1d 1.5.4, liblzma 5.8.4, standalone yt-dlp 2026.08.19 and Deno 2.9.6 inputs. The first build downloads them into ignored `.build/dependencies/<architecture>`; later builds reuse completed preparations and verified downloads. FFmpeg is built as shared LGPL playback libraries, without programs, encoders, muxers, filters or unrelated autodetected packages; dav1d supplies software AV1 and liblzma retains compressed-media support. Native HTTPS uses macOS SecureTransport, not an external OpenSSL installation. The repo carries recipes and checksums, not generated runtime binaries.
|
||||
`Dependencies.lock.json` pins SHA256-verified FFmpeg 9.0.1, dav1d 1.5.4, liblzma 5.8.4, standalone yt-dlp 2026.08.19 and Deno 2.9.6 inputs. The first build downloads them into ignored `.build/dependencies/<architecture>`; later builds reuse completed preparations and verified downloads. FFmpeg is built as shared LGPL playback libraries, without programs, filters or unrelated autodetected packages. Cast delivery enables only the built-in AAC and VideoToolbox H.264 encoders and MPEG-TS muxer; dav1d supplies software AV1 and liblzma retains compressed-media support. Native HTTPS uses macOS SecureTransport, not an external OpenSSL installation. The repo carries recipes and checksums, not generated runtime binaries.
|
||||
|
||||
Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open With**. Successfully opened files start playback automatically, including when replacing the current file. **⇧⌘W** closes the file; closing the window quits and joins the worker.
|
||||
|
||||
@@ -79,6 +79,7 @@ automatic_quality = false
|
||||
[ui]
|
||||
buffer_info = false
|
||||
compact_snap_to_edges = true
|
||||
compact_title_progress = "time"
|
||||
|
||||
[keys]
|
||||
play = ["k", "space"]
|
||||
@@ -118,11 +119,11 @@ Omitted settings/actions retain their defaults. A shortcut string or string arra
|
||||
|
||||
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.
|
||||
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. YouTube captions and caption key actions are not implemented; resolved-source VTT selection uses the captions menu. 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.
|
||||
Behavior/UI switches must be TOML booleans; `[ui].compact_title_progress` is a string: `"time"` (default), `"percentage"`, or `"none"`. Unknown keys, unsupported values, 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.
|
||||
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 and remembered compact width remain separate in UserDefaults and are not reset by `--no-config`; legacy UserDefaults configuration settings are no longer read or written.
|
||||
|
||||
## YouTube
|
||||
|
||||
@@ -136,6 +137,8 @@ Seekable network playback targets **60 seconds ahead and 30 seconds behind** usi
|
||||
|
||||
Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, continuing sequentially and reusing persistent connections when the server permits. This avoids the open-ended-request throttling seen on some YouTube CDNs; URL extraction alone does not apply yt-dlp's download chunking. Native [`request_size` and `multiple_requests`](https://github.com/FFmpeg/FFmpeg/blob/n9.0.1/doc/protocols.texi#L462-L485) handle continuation without a separate downloader. Large `short_seek_size` read-ahead stays disabled: an uncached seek must escape a stalled response, not wait to drain it. Required headers, fifteen-second I/O deadlines, cache limits, and cancellation remain intact. Live sources keep their existing transport behavior. This removes avoidable request-level throttling, not bandwidth/CDN limits: a minute is a target, not a startup requirement or guaranteed reserve.
|
||||
|
||||
Recorded, range-seekable HTTP(S) inputs automatically reconnect after interrupted reads, resuming at the same byte offset without reopening media or discarding retained packets. FFmpeg caps reconnect attempts and backoff; the reader's **fifteen-second total I/O deadline** still applies, with a **five-second socket timeout** leaving room for recovery. Position, playing/paused intent and selected headers remain unchanged. Seek, replacement, close and quit can interrupt retries. Genuine EOF is not retried; live/non-range streams are not restarted. Persistent failures still surface an error. Expired signed URLs are not automatically refreshed.
|
||||
|
||||
**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.
|
||||
@@ -144,9 +147,91 @@ While playing YouTube, choose **gear → Quality** in either normal or compact m
|
||||
|
||||
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, update the pinned helper versions/checksums and rebuild/reinstall terebi; upgrading an external Homebrew yt-dlp does not change the bundled extractor.
|
||||
|
||||
## Resolved HLS sources (Miruro / Tubular)
|
||||
|
||||
Tubular integration handoff: [launch contract, progress schema and verification evidence](docs/miruro-playback-handoff.md).
|
||||
|
||||
Tubular owns discovery, episode/provider selection and fresh source resolution. Terebi accepts a resolved source document; it does not scrape Miruro, embed `strm.cx`, refresh expired sources or bypass protection.
|
||||
|
||||
```sh
|
||||
terebi --new-instance --source-file '/private/session/source.json' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
`--source-file` requires `--new-instance` and accepts a filesystem path (relative paths resolve against the shell's directory). The CLI validates the document, then forwards its absolute path to the GUI; only paths, not stream URLs or header values, enter launch arguments. Create the session directory with mode `0700` and the source file with mode `0600`. Keep the file unchanged and present for the entire player session; launcher success does not acknowledge that the GUI has read it. The caller owns cleanup.
|
||||
|
||||
Source document syntax (replace example URLs with freshly resolved resources):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"url": "https://cdn.example/master.m3u8?token=fresh",
|
||||
"headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"},
|
||||
"identity": {"provider": "miruro", "media_id": "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw", "episode": 1},
|
||||
"title": "The Apothecary Diaries — episode 1",
|
||||
"subtitles": [{
|
||||
"url": "https://cdn.example/english.jpeg?token=fresh",
|
||||
"format": "vtt",
|
||||
"language": "en",
|
||||
"label": "English",
|
||||
"default": true,
|
||||
"headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
All fields except `title` are required; `subtitles: []` and `headers: {}` are valid. Subtitle headers never inherit stream headers: Tubular must explicitly supply the appropriate map for each resource. Values are preserved without trimming; invalid HTTP names, duplicate case-insensitive names and control characters are rejected. Documents are limited to 1 MiB, 32 subtitle tracks and 64 headers per resource. Miruro IDs are nonempty ASCII letters/digits/`-`/`_` (up to 512 bytes); episode numbers are integers from 1 through 2147483647.
|
||||
|
||||
Native FFmpeg plays the master and nested playlists, media segments, AES-128 keys and fMP4 init resources with the stream's headers. Relative URLs use native HLS resolution; supplied URLs and query tokens are retained, not synthesized onto unrelated references. Resolved media resources must remain on the initial stream's origin (scheme, host and effective port). All media redirects, including same-origin redirects, are rejected before a second request; this deliberately prevents FFmpeg from forwarding private headers to an unrelated destination. YouTube keeps its existing transport policy.
|
||||
|
||||
Sun can serve MPEG-TS segments as `.jpg`/`image/jpeg`. Resolved HLS uses content probing instead of FFmpeg's filename-extension gate; URLs are not rewritten. The HTTP(S)-only, same-origin and no-redirect restrictions still apply.
|
||||
|
||||
External subtitles use explicit `format: "vtt"` plus WebVTT content, never the filename extension or MIME type. The captions menu shows labels/languages, selects the declared default and permits Off/another track. Plain-text cues follow acknowledged playback position, including backward/forward seeks; WebVTT positioning, CSS and typographic styling are not applied. Subtitle loading is bounded to 4 MiB with 15-second request/20-second resource timeouts. Same-origin subtitle redirects retain that resource's headers; cross-origin redirects are rejected. Load errors appear beside the captions menu, independently of media errors.
|
||||
|
||||
FFmpeg diagnostics omit private detail strings; playback failures still expose the operation and native error. Source/subtitle validation and transport errors do not interpolate supplied URLs or header values.
|
||||
|
||||
Miruro's progress identity is independent of its source address. YouTube records stay version 1; Miruro records use version 2:
|
||||
|
||||
```json
|
||||
{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"OERe_a0YS4h-DlsBDOeXwX7HOO6316tw","episode":1},"position_seconds":123.5,"duration_seconds":1500.0,"completion_eligible":true}
|
||||
```
|
||||
|
||||
Events, acknowledged-position reporting, nullable duration and completion eligibility retain the semantics below. Miruro bookmarks use the same stable episode identity. **Tubular coordination required:** its current `src/video_state.rs` accepts only canonical YouTube/version-1 records; it must adopt this version-2 identity before Miruro watched tracking works. This is the implemented Terebi contract, not a claim that Tubular has already adopted it.
|
||||
|
||||
`make check-subtitles`, `make check-resolved` and `make check-network` use deterministic local VTT/HLS fixtures; `make check-startup` exercises the actual CLI-to-GUI handoff. Public-provider live playback is never part of the default suite; Cast's rolling-window check uses generated media and an injected pacing clock. With a private, freshly resolved source document, explicitly run `.build/resolved-playback-check /absolute/source.json` for native video/audio and subtitle/seek smoke. Visual rendering and perceived synchronization still require a visible-app check.
|
||||
|
||||
Verification recorded for the direct `ssub`/`sun` source: a freshly resolved native stream decoded video/audio and advanced, selected English VTT, and matched the model's subtitle text to the expected cues after forward/backward acknowledged seeks. Local encrypted MPEG-TS and fMP4 fixtures also verified disguised image suffixes, headers on master/variant/segment/key/init requests, query preservation, foreign-origin/redirect isolation and redacted errors. `TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup` passed actual CLI launch into two independent GUI episodes, resource-specific subtitle headers and version-2 reports. CLI, subtitle, progress, reporting, YouTube resolver and system-playback checks passed.
|
||||
|
||||
The full `make check-startup` rerun passed, including held-key suppression, YouTube progress/end transitions and independent Miruro launches. Earlier runs failed held-Space and YouTube end-transition assertions; possible manual interaction was reported, but causality is not established. The rerun required no playback-code changes. Visual subtitle appearance and perceived A/V synchronization remain human verification, and Tubular's version-2 consumer change remains external coordination.
|
||||
|
||||
|
||||
## Chromecast
|
||||
|
||||
Open media, then choose the **Cast** icon (the Apple TV/AirPlay-style screen-and-triangle symbol) between pin and settings in the top-right titlebar container, or between exit compact mode and settings in compact controls, or **Playback → Cast to Device → Find Cast Devices…**. Allow macOS Local Network access, then select a discovered Chromecast/Cast-enabled TV on the same LAN. The icon does not add AirPlay support. No Chrome, hosted custom receiver, Google developer registration, or external casting runtime is required.
|
||||
|
||||
The Cast icon uses 15 points in the titlebar and 17 points in compact controls, within the same 24 × 22-point button frame.
|
||||
|
||||
Terebi pauses local output before transferring the acknowledged position and play/pause intent to Google's Default Media Receiver. The existing controls, keyboard shortcuts and macOS media commands control remote playback, seeking, volume/mute and resolved-source subtitles. **Play on This Mac** restores receiver-confirmed position and intent; local volume is preserved independently. Connection/load failures return to local playback with an error. Opening another source ends casting and opens it locally. Frame stepping and YouTube quality changes are local-only; automatic quality is suspended while casting.
|
||||
|
||||
TLS connection establishment has a twelve-second deadline; cold receiver launch has a separate thirty-second deadline and accepts validated asynchronous readiness without waiting for the original launch acknowledgement. Volume controls retain the latest requested value until its acknowledgement, ignore older feedback, and do not reposition the native slider during a drag. External TV volume changes still update idle controls.
|
||||
|
||||
The Mac serves session-only capability URLs over HTTP. Keep it awake and connected, and allow incoming connections through its firewall. The relay exposes only the chosen media and subtitle resources, never arbitrary filesystem paths or a general URL proxy. Disconnect, replacement and quit cancel delivery and invalidate the listening endpoint.
|
||||
|
||||
Local files, resolved Miruro HLS and YouTube's combined/separate video/audio inputs are delivered as six-second HLS segments. Compatible, independently decodable H.264/AAC is remuxed; other supported inputs use VideoToolbox H.264 (including its platform software fallback) and stereo AAC. Output targets at most **1920×1080 at 30 fps**, H.264 Main/High level 4.1 or lower, with preserved display aspect ratio. Recorded segments are generated on demand, including distant seeks; there is no full-movie download or preprocessing step. Live output keeps eight segments (about 48 seconds) and is not seekable. Audio-only sources are supported.
|
||||
|
||||
Recorded network delivery uses the native player's bounded compressed-packet buffer: up to 128 MiB shared between inputs, targeting sixty seconds ahead and thirty seconds behind (byte pressure can shorten retention). Combined HLS supplies video/audio from one selected-stream reader; unused quality variants are disabled. Sequential and nearby seeks reuse packets, including decoder/AAC preroll; true cache misses seek upstream once. Nearby forward requests wait for existing read-ahead instead of cancelling and restarting its fetch. Eight completed output segments are also cached (at most 256 MiB), so repeated, HEAD and range requests reuse encoded bytes. Uncached generation is serialized; GOP preroll, transcoding, provider slowness and receiver buffering can still delay a fresh Cast seek.
|
||||
|
||||
Miruro retains its HTTP(S)-only, same-origin media and no-redirect policy. Public YouTube HLS can cross CDN origins, but supplied headers never follow it to another origin. All Cast media redirects are rejected. WebVTT is fetched with each track's own headers and the existing bounded, same-origin subtitle redirect policy, then served with CORS. Private upstream addresses and headers are not sent to the receiver or included in errors.
|
||||
|
||||
The Cast channel accepts the selected receiver's self-signed TLS certificate: encryption on a trusted LAN, **not** Google device authentication or public-CA receiver identity verification. Discovery and receiver control trust the selected LAN endpoint. Native media decoding has a 4096×2160-pixel budget, individual FFmpeg allocations are capped at 128 MiB after Cast initialization, and each outgoing segment is capped at 32 MiB. Recorded inputs longer than fourteen days are rejected.
|
||||
|
||||
`make check-cast` exercises a real self-signed TLS loopback receiver, decoded relay output for all three source paths, header/origin isolation, recorded seeking, VOD/live audio continuity, anamorphic video, capability expiry, controlled protocol deadlines/live pacing, and complete player handoff/back-to-Mac recovery. Regression checks cover slow/asynchronous launch, stale volume feedback, native slider tracking and split-stream seek work: repeated segments add no upstream requests, and indexed distant/adjacent seeks add no origin-zero reprobes. A production-paced live HLS smoke also decoded A/V across a segment boundary and shut down. **YouTube Cast playback has been observed on a physical receiver; these fixes, receiver captions and perceived A/V synchronization still need human verification.**
|
||||
|
||||
Authenticated multi-variant HLS regressions gate upstream responses and verify no unused-quality downloads or duplicate retained-media reads, nearby rewinds, distant cache misses, decoded A/V positions and AAC continuity, and cancellation while upstream remains blocked. Native network playback checks cover the shared reader's existing callers.
|
||||
|
||||
## Resume playback
|
||||
|
||||
Reopening a local video or YouTube video automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL, so short links and newly extracted media URLs share progress. The app does not automatically reopen the last video on launch.
|
||||
Reopening a local video, YouTube video or resolved Miruro episode automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL; Miruro uses its opaque media ID and episode number. Newly resolved addresses therefore share progress without saving CDN tokens as identity. The app does not automatically reopen the last video on launch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -166,7 +251,7 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' \
|
||||
|
||||
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:
|
||||
Each complete YouTube line is a version-1 JSON record (resolved Miruro sources use the version-2 identity documented above):
|
||||
|
||||
```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}
|
||||
@@ -182,15 +267,33 @@ Terebi reports facts, not watched status. A consumer such as Tubular decides com
|
||||
|
||||
## 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.
|
||||
The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point transparent titlebar with native traffic lights and file icon, plus a frosted 440 × 68-point floating controller. Conjure-inspired **Sunset** styling uses translucent dark-brown glass, warm cream primary text, sand secondary text, and a fixed orange accent for the volume fill, active pin, and drop target. Native slider thumbs and buffered ranges share the palette; warning symbols retain their semantic yellow. 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.
|
||||
|
||||
The empty player uses the same tinted HUD-glass material, faint warm outlines, 12-point rounded corners, and Sunset palette in a centered welcome card. File and YouTube opening actions are separate, equally sized buttons with a 10-point gap, subtle orange fills, and matching half-point sand outlines. The YouTube button toggles an outlined inline URL field below the buttons, spanning their combined width including the gap. Its placeholder uses the secondary sand text color at 60% opacity to distinguish it from entered text. Showing it focuses the field; hiding it releases text-input focus and preserves the draft. The field fades in and out without a slide while the card adjusts over 220 ms with ease-in/out timing; Reduce Motion disables the animation. Return or the arrow button submits through the existing YouTube URL validation. Other URL-opening entry points retain their dialog. The card fits the minimum window, and the full window still accepts media-file and YouTube-link drops.
|
||||
|
||||
The inline URL field, including its placeholder, uses the same 12-point system font as the drop hint below it. Its height follows the content with 4-point vertical padding rather than matching the taller opening buttons; the submit arrow retains its 24-point click target.
|
||||
|
||||
With no media open or while casting, the full window uses native behind-window HUD glass with a 35%-opaque brown tint. Static, cached amber/rust radial gradients add ambient color to the empty and casting screens; they disappear during loading or errors, and are never drawn over local video or playback chrome. Glass title/control/error surfaces use the same brown tint with half-point sand outlines. Decorative drawing does not intercept input. The idle or casting Metal surface stays attached but hidden; local playback retains a solid black background and letterboxing without tinting the video. Closing media or starting casting restores the frosted background. macOS controls the material’s appearance, including Reduce Transparency.
|
||||
|
||||
In normal mode, the right-hand playback control group is captions, then compact mode. The captions selector is omitted when no external subtitle tracks are available, without reserving a caption slot. Its native symbol uses a 13-point configuration within a 24-point click target. The native pull-down menu includes a visible Off option and prefers opening above the button; AppKit can reposition it to fit the screen. In compact mode, subtitle selection and Off are available through the settings button's Subtitles submenu; the compact cog uses a 21-point symbol to balance the adjacent icons. The settings menu uses a plain button style so its symbol respects that size and inherits the same cream icon color as the other controls, rather than the orange accent tint. Subtitle glass hugs the text with 16-point horizontal and 12-point vertical internal padding, with the complete container capped at 80% of the window width; longer text wraps within that limit. Its HUD-glass background uses 60% opacity without dimming the text. Visible compact controls raise subtitles by the actual compact control height plus spacing, rather than the normal controller's clearance. Subtitle-load errors remain visible independently of auto-hidden controls.
|
||||
|
||||
Accepted relative seeks, including keyboard shortcuts, preserve control visibility instead of revealing hidden chrome. Seek failures still reveal the error; dragging the timeline continues to show the controls.
|
||||
|
||||
Normal and compact titlebars have no full-width glass strip or separator: content extends beneath AppKit’s transparent titlebar using `fullSizeContentView`, `titlebarAppearsTransparent`, and `titlebarSeparatorStyle = .none`. Titles retain their rounded HUD-glass backdrop. In normal mode, pin, Cast, and settings share a separate container matching the title's 24-point height, horizontal padding, vertical inset, tint, outline and corner radius. Cast sits between pin and settings; all three retain 24-point click targets. That button container is not shown in compact mode; only the compact title and adjacent progress occupy the top row with 8-point outer insets. Native traffic lights and window dragging are preserved.
|
||||
|
||||
Compact titles default to `<title> · <position> / <duration> <unit>`, using acknowledged playback position rather than slider previews. Total duration selects the format: below one minute, `12 / 45 s`; from one minute to below one hour, `05:12 / 42:08 m`; from one hour onward, `01:23 / 02:10 h`. Values truncate to the displayed precision. Set `[ui].compact_title_progress = "percentage"` to append only a whole percentage number (`<title> · 59`, without a percent sign), or `"none"` for title-only display. Percentages truncate and stay within 0–100. Loading, failed, live, unknown-duration and closed media have no progress suffix. Normal-mode titles remain unchanged.
|
||||
|
||||
Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window.
|
||||
|
||||
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available.
|
||||
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and the last manually chosen compact width (320 points before any resize). The width persists across mode changes and player restarts; height follows the current video's display aspect where usable screen bounds and the 160 × 90-point minimum allow, with extreme ratios retaining letterboxing. Normal-mode resize, transition animations, and automatic aspect/screen fitting do not overwrite the saved preference. Screen fitting may temporarily reduce the displayed size without reducing the saved width. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Its three-button hover row is play/pause, restore, then settings on the right. Settings includes subtitle selection and Off; keyboard playback controls remain available.
|
||||
|
||||
Compact-mode shortcuts and menu actions remain available during YouTube URL resolution, media opening, and network buffering. While metadata is unavailable, the window uses a provisional video ratio; decoded metadata then updates its geometry without leaving compact mode.
|
||||
|
||||
Entry and restoration animate the same window's position and size over **250 ms** with ease-in/ease-out timing while normal and compact controls cross-fade; playback and the Metal view stay in place. macOS **Reduce Motion** disables both animations. Rapid toggles keep the latest destination and finish the current native frame animation before changing direction; native fullscreen transitions are also serialized. Media close, errors, shutdown, and explicit fullscreen handoff restore immediately rather than leave a frame animation pending.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
Default shortcuts and their explicit action names are listed in **Configuration**. Playback 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. Native Quit remains available while editing: its menu prefers the first configured Command chord (⌘Q by default), falling back to the first binding when none has Command. Plain q remains text input while editing. Configured modifier combinations work in playback. Holding a playback/window/settings key performs it once; seek, volume, and frame-step keys may repeat. Other 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.
|
||||
|
||||
@@ -199,8 +302,8 @@ Initial window setup and returning from the file/YouTube dialogs leave keyboard
|
||||
Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame.
|
||||
|
||||
- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking normal-mode video also toggles fullscreen.
|
||||
- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray.
|
||||
- **Single-click normal-mode video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the normal titlebar or either controller, dragging, seeking, an inactive normal-mode window, errors, or VoiceOver keep them visible. Compact controls can fade while another app is active; hovering its video reveals them without hiding the desktop cursor.
|
||||
- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The enabled volume thumb is opaque cream; secondary sand marks the seek thumb and buffered range, with dimmed disabled thumbs.
|
||||
- **Single-click normal-mode video:** hide controls. Mouse movement reveals them; video controls fade after 2.5 seconds, including while paused. While the pointer is inside, hovering the normal titlebar or either controller and an inactive normal-mode window keep them visible; audio-only controls remain visible. Leaving the window starts the title/control fade immediately in either mode, including paused, loading, and audio-only playback; late hover/focus updates do not reshow them. Reentry reveals them. Active dragging/seeking, errors, empty-window opening actions, and VoiceOver remain protected; a drag released or seek acknowledged outside hides the chrome. Tab navigation can explicitly reveal controls without moving the pointer. Compact hover and window exit never hide the desktop cursor.
|
||||
- Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top.
|
||||
|
||||
Only implemented actions have controls; there are no placeholder playlist, subtitle, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift).
|
||||
@@ -213,13 +316,17 @@ Playback terms are defined in [CONTEXT.md](CONTEXT.md).
|
||||
- `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/network_policy.hpp`: shared URL, header and origin validation used by local network playback and Cast delivery.
|
||||
- `Sources/Core/cast_media.h` and `.cpp`: bounded native LAN HLS serving, on-demand recorded segments, rolling live delivery, remux/transcode, subtitle capabilities and cancellation.
|
||||
- `Sources/App/CastSession.swift`: Bonjour discovery, bounded Cast v2 TLS/protobuf framing, Default Media Receiver ownership, acknowledged controls, heartbeat and cancellation.
|
||||
- `Sources/App/CastMediaServer.swift` and `CastControls.swift`: off-main native delivery lifetime, shared authenticated WebVTT transport, and device menus.
|
||||
- `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 `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, and magnetic edge snapping with a session-only toggle.
|
||||
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, serialized frame/fullscreen animations with cancellation, Reduce Motion 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, strict bundled yt-dlp/Deno 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. 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 bundled-library 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 five versioned library filenames relative to `Contents/Frameworks`.
|
||||
@@ -236,9 +343,11 @@ make check
|
||||
|
||||
Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing and queued seek acknowledgement, exact bidirectional VFR/B-frame stepping across keyframes, repeated-step bursts beyond the ordinary command capacity, mixed seek/direction ordering, replacement media superseding a long pending run, first/last-frame boundaries, stepping pause/resume, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test.
|
||||
|
||||
`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-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, coarse container timestamps, and AAC in MPEG-TS with millisecond-rounded timestamps. Audio continuity allows up to two milliseconds of timestamp jitter (or a larger container tick) without inserting or dropping samples; this covers Miruro streams whose advertised 90 kHz timebase overstates their timestamp precision. 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, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
|
||||
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, remembered manual compact width across reentry/controller recreation with a different aspect, isolation from programmatic/normal-window resizing, 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.
|
||||
|
||||
The native window check also covers queued entry/exit reversals, exact snapshot recovery, urgent nonanimated restoration, close/reattachment ignoring stale completions, and injected Reduce Motion. Animation smoothness and control cross-fades still need visual verification 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.
|
||||
|
||||
@@ -246,14 +355,20 @@ Generates short fixtures and exercises the real playback interface and native ou
|
||||
|
||||
`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-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.
|
||||
The network check also disconnects combined, secondary-audio and primary-video responses after native playback starts. It verifies retained coverage, unchanged playing/paused intent, decoded continuation beyond the interrupted prefix, actual socket-timeout recovery, bounded persistent failures, and seek/close/replacement/destruction during retry.
|
||||
|
||||
`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`; native adaptation uses the app's bundled yt-dlp and Deno. No external network is required.
|
||||
|
||||
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.
|
||||
|
||||
The gated YouTube check also exercises compact shortcut/control entry and restoration during initial resolution, staying compact across empty playback updates and loading completion, and switching modes while network playback is buffering.
|
||||
|
||||
`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-cast` is included in `make check`; it needs no TV or live provider. `make check-network` also verifies that immediate close drains receiver-confirmed progress rather than the abandoned decoder position.
|
||||
|
||||
`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, unsafe paths/versions, unavailable and unloadable dylibs, omitted library families, and successful native loading from an isolated relocated copy of the bundled Frameworks. It is included in `make check`; no installed Homebrew files are modified.
|
||||
@@ -264,7 +379,9 @@ Adaptive transition checks also gate a real extraction, cancel it by pausing, an
|
||||
|
||||
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.
|
||||
Native opening checks enter compact before playback acknowledges the request, then verify that anamorphic video metadata replaces the provisional ratio and that closing restores normal mode.
|
||||
|
||||
`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, plain q insertion and queued ⌘Q quitting with text-input focus while draining final progress, 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.
|
||||
|
||||
@@ -276,10 +393,10 @@ Visual correctness, perceived A/V synchronization, and control usability still n
|
||||
|
||||
## Current limits
|
||||
|
||||
Local regular files and YouTube video URLs, selectable YouTube resolutions, automatically selected audio/video tracks within the chosen quality, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, explicit codec/audio-track selection, VideoToolbox decoding, or rotation/deinterlacing. Mid-track audio format changes report an error. The URL UI accepts YouTube videos rather than arbitrary web pages or streaming services.
|
||||
Local regular files, YouTube video URLs and resolved Miruro HTTP(S) sources, selectable YouTube resolutions, automatically selected audio/video tracks within the chosen quality, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. External resolved-source WebVTT is supported; embedded/YouTube captions, explicit codec/audio-track selection, VideoToolbox decoding and rotation/deinterlacing are not. Mid-track audio format changes report an error. The URL UI accepts YouTube videos; other resolved media uses `--source-file`.
|
||||
|
||||
Forward frame stepping retains the live video decoder cursor instead of seeking and re-decoding the same keyframe group for each repeat. Backward stepping still scans from an earlier keyframe; long-GOP or unindexed files can therefore step backward slowly, without blanking the image or allocating a command per key repeat. Audio is skipped while stepping and rebuilt once on resume from the verified preroll anchor; resuming after a long forward run can also require a longer preroll. Intermediate frames needed by inter-frame dependencies are decoded and discarded; frames are not cached across the entire file. Every accepted step counts. Stepping requires usable decoded presentation timestamps and reports an error rather than inventing frame times when they are absent.
|
||||
|
||||
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 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.
|
||||
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, including bounded reconnection for recorded range-seekable HTTP inputs; there is no automatic signed-URL refresh.
|
||||
|
||||
@@ -20,5 +20,7 @@
|
||||
</array>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>NSPrincipalClass</key><string>NSApplication</string>
|
||||
<key>NSLocalNetworkUsageDescription</key><string>Discover Cast devices and stream your selected media to a TV on your local network.</string>
|
||||
<key>NSBonjourServices</key><array><string>_googlecast._tcp</string></array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -144,6 +144,7 @@ def native(root, arch, lock):
|
||||
"--extra-ldflags=" + flags + " " + shlex.quote("-L" + str(root / "xz/lib")),
|
||||
"--enable-shared", "--disable-static", "--disable-programs", "--disable-doc",
|
||||
"--disable-encoders", "--disable-muxers", "--disable-avdevice", "--disable-avfilter",
|
||||
"--enable-encoder=aac,h264_videotoolbox", "--enable-muxer=mpegts",
|
||||
"--disable-gpl", "--disable-nonfree", "--disable-version3", "--disable-autodetect",
|
||||
"--enable-libdav1d", "--enable-securetransport", "--enable-videotoolbox",
|
||||
"--enable-audiotoolbox", "--enable-zlib", "--enable-bzlib", "--enable-lzma"]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct CastDeviceMenu: View {
|
||||
@ObservedObject var model: PlayerModel
|
||||
@ObservedObject private var discovery: CastDiscovery
|
||||
var icon = true
|
||||
|
||||
init(model: PlayerModel, icon: Bool = true) {
|
||||
self.model = model
|
||||
self.discovery = model.castDiscovery
|
||||
self.icon = icon
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
if let device = model.castDevice {
|
||||
Text((model.castConnecting ? "Connecting to " : "Casting to ") + device.name)
|
||||
Button(model.castConnecting ? "Cancel Casting" : "Play on This Mac") { model.stopCasting() }
|
||||
Divider()
|
||||
}
|
||||
ForEach(discovery.devices) { device in
|
||||
Button(device.name) { model.cast(to: device) }
|
||||
.disabled(!model.canCast)
|
||||
}
|
||||
if discovery.devices.isEmpty {
|
||||
Text("No Cast devices discovered")
|
||||
}
|
||||
if let error = discovery.error { Text(error) }
|
||||
Divider()
|
||||
Button("Find Cast Devices…") { discovery.start() }
|
||||
} label: {
|
||||
if icon {
|
||||
Image(systemName: "airplayvideo")
|
||||
.font(.system(size: model.isCompact ? 17 : 15))
|
||||
.frame(width: 24, height: 22)
|
||||
} else {
|
||||
Text("Cast to Device")
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.accessibilityLabel("Cast to Device")
|
||||
.help(model.castDevice.map { "Casting to " + $0.name } ?? "Cast to Device")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
struct CastMediaInput: Sendable {
|
||||
let videoURL: String
|
||||
let videoHeaders: String
|
||||
let audioURL: String?
|
||||
let audioHeaders: String?
|
||||
let local: Bool
|
||||
let restricted: Bool
|
||||
let live: Bool
|
||||
let duration: Double
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
}
|
||||
|
||||
struct CastMediaDescription: Sendable {
|
||||
let url: URL
|
||||
let contentType: String
|
||||
let duration: Double
|
||||
let live: Bool
|
||||
let tracks: [CastTextTrack]
|
||||
}
|
||||
|
||||
struct CastTextTrack: Sendable {
|
||||
let id: Int
|
||||
let url: URL
|
||||
let language: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
private struct CastDeliveryError: LocalizedError {
|
||||
let errorDescription: String? = "Could not deliver cast media. Check the connection and media compatibility. Private details are redacted."
|
||||
}
|
||||
|
||||
// A start task retains this owner until native probing returns. Cancellation never
|
||||
// frees a handle in use; destruction and worker joins always run off the main actor.
|
||||
private final class CastMediaLifetime: @unchecked Sendable {
|
||||
let handle: OpaquePointer
|
||||
init(input: CastMediaInput) throws {
|
||||
var error = [CChar](repeating: 0, count: 512)
|
||||
let result = input.videoURL.withCString { video in
|
||||
input.videoHeaders.withCString { headers in
|
||||
(input.audioURL ?? "").withCString { audio in
|
||||
(input.audioHeaders ?? "").withCString { audioHeaders in
|
||||
var source = CastMediaSource(video_url: video, video_headers: headers,
|
||||
audio_url: audio, audio_headers: audioHeaders, local: input.local,
|
||||
restricted: input.restricted, live: input.live, duration: input.duration)
|
||||
return cast_media_create(&source, &error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let result else { throw CastDeliveryError() }
|
||||
handle = result
|
||||
}
|
||||
func cancel() { cast_media_cancel(handle) }
|
||||
func start(host: String) throws -> (String, Double) {
|
||||
var url = [CChar](repeating: 0, count: 512)
|
||||
var error = [CChar](repeating: 0, count: 512)
|
||||
var duration = 0.0
|
||||
guard host.withCString({ cast_media_start(handle, $0, &url, &duration, &error) }) else {
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
return (url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }, duration)
|
||||
}
|
||||
func subtitle(id: Int, data: Data) throws -> URL {
|
||||
var url = [CChar](repeating: 0, count: 512)
|
||||
let success = data.withUnsafeBytes {
|
||||
cast_media_subtitle(handle, Int32(id), $0.baseAddress, $0.count, &url)
|
||||
}
|
||||
guard success, let result = URL(string: url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }) else { throw CastDeliveryError() }
|
||||
return result
|
||||
}
|
||||
deinit {
|
||||
// Capture an explicitly Sendable owner instead of crossing an actor with a pointer.
|
||||
let cleanup = CastMediaCleanup(handle: handle)
|
||||
DispatchQueue.global(qos: .utility).async { cast_media_destroy(cleanup.handle) }
|
||||
}
|
||||
}
|
||||
private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer }
|
||||
|
||||
@MainActor final class CastMediaServer {
|
||||
private var lifetime: CastMediaLifetime?
|
||||
private var operation: Task<CastMediaDescription, Error>?
|
||||
|
||||
init() {}
|
||||
|
||||
func start(input: CastMediaInput, receiverHost: String) async throws -> CastMediaDescription {
|
||||
stop()
|
||||
guard input.subtitles.count <= 32 else { throw CastDeliveryError() }
|
||||
let owner = try CastMediaLifetime(input: input)
|
||||
lifetime = owner
|
||||
let task = Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
let (text, duration) = try owner.start(host: receiverHost)
|
||||
try Task.checkCancellation()
|
||||
guard let url = URL(string: text) else { throw CastDeliveryError() }
|
||||
var tracks: [CastTextTrack] = []
|
||||
for (index, subtitle) in input.subtitles.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
let data = try await WebVTTDocument.loadData(url: subtitle.url, headers: subtitle.headers)
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() }
|
||||
_ = try WebVTTDocument.parse(text)
|
||||
let trackURL = try owner.subtitle(id: index + 1, data: data)
|
||||
tracks.append(CastTextTrack(id: index + 1, url: trackURL,
|
||||
language: subtitle.language, label: subtitle.label))
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return CastMediaDescription(url: url, contentType: "application/vnd.apple.mpegurl",
|
||||
duration: duration, live: input.live, tracks: tracks)
|
||||
} catch {
|
||||
owner.cancel()
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
}
|
||||
operation = task
|
||||
do {
|
||||
let result = try await withTaskCancellationHandler {
|
||||
try await task.value
|
||||
} onCancel: {
|
||||
owner.cancel()
|
||||
task.cancel()
|
||||
}
|
||||
guard lifetime === owner else { throw CancellationError() }
|
||||
operation = nil
|
||||
return result
|
||||
} catch {
|
||||
if lifetime === owner { stop() }
|
||||
if error is CancellationError { throw error }
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
operation?.cancel()
|
||||
operation = nil
|
||||
lifetime?.cancel()
|
||||
lifetime = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
operation?.cancel()
|
||||
lifetime?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
@preconcurrency import Network
|
||||
import Security
|
||||
|
||||
struct CastDevice: Identifiable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let host: String
|
||||
let port: UInt16
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CastDiscovery: NSObject, ObservableObject, @preconcurrency NetServiceBrowserDelegate,
|
||||
@preconcurrency NetServiceDelegate {
|
||||
@Published private(set) var devices: [CastDevice] = []
|
||||
@Published private(set) var error: String?
|
||||
private var browser: NetServiceBrowser?
|
||||
private var services: [String: NetService] = [:]
|
||||
private var resolved: [String: CastDevice] = [:]
|
||||
|
||||
func start() {
|
||||
if error != nil { stop() }
|
||||
guard browser == nil else { return }
|
||||
error = nil
|
||||
let browser = NetServiceBrowser()
|
||||
self.browser = browser
|
||||
browser.delegate = self
|
||||
browser.searchForServices(ofType: "_googlecast._tcp.", inDomain: "local.")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.stop()
|
||||
browser?.delegate = nil
|
||||
browser = nil
|
||||
for service in services.values { service.stop(); service.delegate = nil }
|
||||
services.removeAll()
|
||||
resolved.removeAll()
|
||||
devices.removeAll()
|
||||
}
|
||||
|
||||
private func key(_ service: NetService) -> String { service.domain + service.type + service.name }
|
||||
private func publish() {
|
||||
// Multiple advertised interfaces may resolve to the same receiver ID.
|
||||
var unique: [String: CastDevice] = [:]
|
||||
for device in resolved.values { unique[device.id] = device }
|
||||
devices = unique.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
|
||||
guard browser === self.browser, services.count < 128 else { return }
|
||||
let key = self.key(service)
|
||||
guard services[key] == nil else { return }
|
||||
services[key] = service
|
||||
service.delegate = self
|
||||
service.resolve(withTimeout: 5)
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
|
||||
guard browser === self.browser else { return }
|
||||
let key = self.key(service)
|
||||
services.removeValue(forKey: key)?.stop()
|
||||
resolved.removeValue(forKey: key)
|
||||
publish()
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
|
||||
guard browser === self.browser else { return }
|
||||
error = "Cast discovery failed. Check Local Network permission and your Wi-Fi connection."
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
let key = self.key(sender)
|
||||
guard services[key] === sender, let host = sender.hostName,
|
||||
sender.port > 0, sender.port <= Int(UInt16.max) else { return }
|
||||
let txt = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
|
||||
func text(_ key: String) -> String? {
|
||||
txt[key].flatMap { String(data: $0, encoding: .utf8) }.flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
resolved[key] = CastDevice(id: text("id") ?? key, name: text("fn") ?? sender.name,
|
||||
host: host, port: UInt16(sender.port))
|
||||
publish()
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
let key = self.key(sender)
|
||||
guard services[key] === sender else { return }
|
||||
services.removeValue(forKey: key)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
struct CastStatus: Sendable {
|
||||
let position: Double
|
||||
let duration: Double
|
||||
let playing: Bool
|
||||
let buffering: Bool
|
||||
let ended: Bool
|
||||
let volume: Double
|
||||
let seekable: Bool
|
||||
let subtitle: Int?
|
||||
let seekPending: Bool
|
||||
let playbackPending: Bool
|
||||
}
|
||||
|
||||
enum CastError: Error, LocalizedError, Sendable {
|
||||
case protocolError, disconnected, cancelled, timeout(String), rejected(String), invalidMedia, queueFull
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .protocolError: "The Cast receiver sent an invalid message."
|
||||
case .disconnected: "The Cast receiver disconnected."
|
||||
case .cancelled: "The Cast operation was cancelled."
|
||||
case .timeout(let operation): "The Cast receiver did not acknowledge \(operation) in time."
|
||||
case .rejected(let operation): "The Cast receiver rejected \(operation)."
|
||||
case .invalidMedia: "The media or subtitle selection cannot be cast."
|
||||
case .queueFull: "Too many Cast commands are waiting for a response."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cast v2's original (unchunked) envelope, from Open Screen's cast_channel.proto:
|
||||
// https://chromium.googlesource.com/openscreen/+/refs/heads/main/cast/common/channel/proto/cast_channel.proto
|
||||
// Media transport messages are limited to 64 KiB by https://developers.google.com/cast/docs/media/messages.
|
||||
enum CastWire {
|
||||
static let maximumFrame = 64 * 1024
|
||||
static let connection = "urn:x-cast:com.google.cast.tp.connection"
|
||||
static let heartbeat = "urn:x-cast:com.google.cast.tp.heartbeat"
|
||||
static let receiver = "urn:x-cast:com.google.cast.receiver"
|
||||
static let media = "urn:x-cast:com.google.cast.media"
|
||||
|
||||
struct Message: Sendable {
|
||||
let source: String
|
||||
let destination: String
|
||||
let namespace: String
|
||||
let payload: Data
|
||||
}
|
||||
|
||||
static func varint(_ value: UInt64, into data: inout Data) {
|
||||
var value = value
|
||||
repeat {
|
||||
let byte = UInt8(value & 127)
|
||||
value >>= 7
|
||||
data.append(byte | (value == 0 ? 0 : 128))
|
||||
} while value != 0
|
||||
}
|
||||
|
||||
static func frame(_ message: Message) throws -> Data {
|
||||
var body = Data([8, 0]) // protocol_version CASTV2_1_0
|
||||
for (field, text) in [(2, message.source), (3, message.destination), (4, message.namespace)] {
|
||||
let bytes = Data(text.utf8)
|
||||
varint(UInt64(field << 3 | 2), into: &body)
|
||||
varint(UInt64(bytes.count), into: &body)
|
||||
body.append(bytes)
|
||||
}
|
||||
body.append(contentsOf: [40, 0, 50]) // payload_type STRING; payload_utf8
|
||||
varint(UInt64(message.payload.count), into: &body)
|
||||
body.append(message.payload)
|
||||
guard body.count <= maximumFrame else { throw CastError.protocolError }
|
||||
let count = UInt32(body.count)
|
||||
var frame = Data([UInt8(count >> 24), UInt8((count >> 16) & 255),
|
||||
UInt8((count >> 8) & 255), UInt8(count & 255)])
|
||||
frame.append(body)
|
||||
return frame
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> Message {
|
||||
guard !data.isEmpty, data.count <= maximumFrame else { throw CastError.protocolError }
|
||||
var offset = 0
|
||||
func integer() throws -> UInt64 {
|
||||
var value: UInt64 = 0
|
||||
for shift in stride(from: 0, through: 63, by: 7) {
|
||||
guard offset < data.count else { throw CastError.protocolError }
|
||||
let byte = data[data.startIndex + offset]
|
||||
offset += 1
|
||||
guard shift != 63 || byte <= 1 else { throw CastError.protocolError }
|
||||
value |= UInt64(byte & 127) << shift
|
||||
if byte & 128 == 0 { return value }
|
||||
}
|
||||
throw CastError.protocolError
|
||||
}
|
||||
var numbers: [Int: UInt64] = [:]
|
||||
var bytes: [Int: Data] = [:]
|
||||
while offset < data.count {
|
||||
let key = try integer()
|
||||
guard key >> 3 > 0, key >> 3 <= UInt64(Int32.max) else { throw CastError.protocolError }
|
||||
let field = Int(key >> 3)
|
||||
switch key & 7 {
|
||||
case 0:
|
||||
let value = try integer()
|
||||
if field == 1 || field == 5 || field == 8 { numbers[field] = value }
|
||||
case 1, 5:
|
||||
let size = key & 7 == 1 ? 8 : 4
|
||||
guard size <= data.count - offset else { throw CastError.protocolError }
|
||||
offset += size
|
||||
case 2:
|
||||
let size = try integer()
|
||||
guard size <= UInt64(data.count - offset) else { throw CastError.protocolError }
|
||||
let end = offset + Int(size)
|
||||
if [2, 3, 4, 6].contains(field) {
|
||||
bytes[field] = data.subdata(in: (data.startIndex + offset)..<(data.startIndex + end))
|
||||
}
|
||||
offset = end
|
||||
default: throw CastError.protocolError
|
||||
}
|
||||
}
|
||||
guard numbers[1] == 0, numbers[5] == 0, numbers[8, default: 0] == 0,
|
||||
let sourceData = bytes[2], let source = String(data: sourceData, encoding: .utf8), !source.isEmpty,
|
||||
let destinationData = bytes[3], let destination = String(data: destinationData, encoding: .utf8), !destination.isEmpty,
|
||||
let namespaceData = bytes[4], let namespace = String(data: namespaceData, encoding: .utf8), !namespace.isEmpty,
|
||||
let payload = bytes[6], String(data: payload, encoding: .utf8) != nil else {
|
||||
throw CastError.protocolError
|
||||
}
|
||||
return Message(source: source, destination: destination, namespace: namespace, payload: payload)
|
||||
}
|
||||
|
||||
struct Stream {
|
||||
private var buffer = Data()
|
||||
mutating func append(_ data: Data) throws -> [Message] {
|
||||
// Network reads are <= 16 KiB. Bound incomplete input even before its length is known.
|
||||
guard data.count <= maximumFrame, buffer.count + data.count <= maximumFrame * 2 + 4 else {
|
||||
throw CastError.protocolError
|
||||
}
|
||||
buffer.append(data)
|
||||
var messages: [Message] = []
|
||||
while buffer.count >= 4 {
|
||||
let i = buffer.startIndex
|
||||
let length = buffer[i..<i + 4].reduce(0) { ($0 << 8) | Int($1) }
|
||||
guard length > 0, length <= maximumFrame else { throw CastError.protocolError }
|
||||
guard buffer.count >= length + 4 else { break }
|
||||
messages.append(try decode(buffer.subdata(in: (i + 4)..<(i + 4 + length))))
|
||||
buffer.removeFirst(length + 4)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CastSession {
|
||||
var onStatus: ((CastStatus) -> Void)?
|
||||
var onDisconnect: ((String?) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
var localHost: String? {
|
||||
guard let endpoint = connection?.currentPath?.localEndpoint,
|
||||
case .hostPort(let host, _) = endpoint else { return nil }
|
||||
return String(describing: host)
|
||||
}
|
||||
|
||||
private struct Pending {
|
||||
let namespace: String
|
||||
let destination: String
|
||||
let operation: String
|
||||
let generation: UUID?
|
||||
let completion: ([String: Any]?, CastError?) -> Void
|
||||
let deadline: Task<Void, Never>
|
||||
}
|
||||
|
||||
private let makeConnection: (CastDevice) -> NWConnection
|
||||
private let requestTimeout: Duration
|
||||
private let loadTimeout: Duration
|
||||
private let heartbeatInterval: Duration
|
||||
private let sleep: @MainActor (Duration) async throws -> Void
|
||||
private var connection: NWConnection?
|
||||
private var connectionID = UUID()
|
||||
private let source = "terebi-" + UUID().uuidString
|
||||
private var stream = CastWire.Stream()
|
||||
private var ready: CheckedContinuation<Void, any Error>?
|
||||
private var readyDeadline: Task<Void, Never>?
|
||||
private var heartbeatTask: Task<Void, Never>?
|
||||
private var awaitingPong = false
|
||||
private var nextRequest = 0
|
||||
private var pending: [Int: Pending] = [:]
|
||||
private var writes: [Data] = []
|
||||
private var writing = false
|
||||
private var launchRequest: Int?
|
||||
private var appTransport: String?
|
||||
private var appSession: String?
|
||||
private var mediaSession: Int?
|
||||
private var retiredSessions: Set<Int> = []
|
||||
private var retiredLoads: [String] = []
|
||||
private var contentID: String?
|
||||
private var mediaGeneration: UUID?
|
||||
private var tracks: [CastTextTrack] = []
|
||||
private var latestStatus: CastStatus?
|
||||
private var pendingSeek: Int?
|
||||
private var pendingPlayback: Int?
|
||||
var playbackPending: Bool { pendingPlayback != nil || replayTask != nil || loading }
|
||||
private var lastStatusPoll = 0
|
||||
private var newestMediaReply = 0
|
||||
private var loading = false
|
||||
private var loadedMedia: CastMediaDescription?
|
||||
private var loadedTitle = ""
|
||||
private var replayTask: Task<Void, Never>?
|
||||
private var replayDesiredPlaying = true
|
||||
private var receiverVolume = 1.0
|
||||
private var pendingVolume: (request: Int, level: Double)?
|
||||
private var latestVolumeRequest = 0
|
||||
|
||||
init(requestTimeout: Duration = .seconds(8), loadTimeout: Duration = .seconds(25),
|
||||
heartbeatInterval: Duration = .seconds(5),
|
||||
sleep: @escaping @MainActor (Duration) async throws -> Void = { try await Task.sleep(for: $0) }) {
|
||||
makeConnection = { device in
|
||||
let tls = NWProtocolTLS.Options()
|
||||
sec_protocol_options_set_min_tls_protocol_version(tls.securityProtocolOptions, .TLSv12)
|
||||
// Cast receivers use self-signed TLS certificates, not public Web PKI. This is
|
||||
// encrypted local-network transport to the user's selected Bonjour endpoint,
|
||||
// NOT authenticated receiver identity. mDNS and this channel are LAN-trust;
|
||||
// never claim standard CA/hostname validation or send upstream credentials.
|
||||
// Cast device-auth (Google device roots) is intentionally not impersonated.
|
||||
sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { @Sendable _, _, complete in
|
||||
complete(true)
|
||||
}, DispatchQueue.global(qos: .userInitiated))
|
||||
let parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
|
||||
return NWConnection(host: NWEndpoint.Host(device.host),
|
||||
port: NWEndpoint.Port(rawValue: device.port)!, using: parameters)
|
||||
}
|
||||
self.requestTimeout = requestTimeout
|
||||
self.loadTimeout = loadTimeout
|
||||
self.heartbeatInterval = heartbeatInterval
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
||||
|
||||
func connect(to device: CastDevice) async throws {
|
||||
disconnect(stopReceiver: false)
|
||||
guard device.port > 0, !device.host.isEmpty else { throw CastError.disconnected }
|
||||
let id = UUID()
|
||||
connectionID = id
|
||||
let socket = makeConnection(device)
|
||||
connection = socket
|
||||
stream = CastWire.Stream()
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
ready = continuation
|
||||
readyDeadline = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do { try await self.sleep(.seconds(12)) } catch { return }
|
||||
guard self.connectionID == id else { return }
|
||||
self.fail(.timeout("connection"))
|
||||
}
|
||||
socket.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.receive(socket, id: id)
|
||||
self.beginHandshake()
|
||||
case .failed, .cancelled: self.fail(.disconnected)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
self.fail(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginHandshake() {
|
||||
do {
|
||||
// TLS establishment and a cold receiver application have separate deadlines.
|
||||
readyDeadline?.cancel()
|
||||
readyDeadline = nil
|
||||
try send(["type": "CONNECT", "origin": [:], "userAgent": "Terebi"],
|
||||
namespace: CastWire.connection, destination: "receiver-0")
|
||||
startHeartbeat()
|
||||
let id = allocateRequest()
|
||||
launchRequest = id
|
||||
try request(["type": "LAUNCH", "appId": "CC1AD845"], namespace: CastWire.receiver,
|
||||
destination: "receiver-0", operation: "receiver launch", timeout: .seconds(30), id: id) { [weak self] _, error in
|
||||
if let error { self?.fail(error) }
|
||||
}
|
||||
} catch { fail(error as? CastError ?? .protocolError) }
|
||||
}
|
||||
|
||||
func load(_ media: CastMediaDescription, title: String, position: Double,
|
||||
playing: Bool, subtitle: Int?) async throws {
|
||||
guard let transport = appTransport, connection != nil else { throw CastError.disconnected }
|
||||
guard position.isFinite, position >= 0, media.duration.isFinite,
|
||||
["http", "https"].contains(media.url.scheme?.lowercased() ?? ""),
|
||||
media.url.absoluteString.utf8.count <= 1024,
|
||||
subtitle == nil || media.tracks.indices.contains(subtitle!), media.tracks.count <= 128 else {
|
||||
throw CastError.invalidMedia
|
||||
}
|
||||
cancelMediaRequests()
|
||||
if let mediaSession {
|
||||
if retiredSessions.count >= 16 { retiredSessions.removeAll() }
|
||||
retiredSessions.insert(mediaSession)
|
||||
}
|
||||
if let mediaGeneration {
|
||||
if retiredLoads.count == 64 { retiredLoads.removeFirst() }
|
||||
retiredLoads.append(mediaGeneration.uuidString)
|
||||
}
|
||||
mediaSession = nil
|
||||
latestStatus = nil
|
||||
pendingSeek = nil
|
||||
pendingPlayback = nil
|
||||
newestMediaReply = 0
|
||||
let generation = UUID()
|
||||
mediaGeneration = generation
|
||||
contentID = media.url.absoluteString
|
||||
tracks = media.tracks
|
||||
loadedMedia = media
|
||||
loadedTitle = title
|
||||
loading = true
|
||||
var info: [String: Any] = [
|
||||
"contentId": media.url.absoluteString, "contentType": media.contentType,
|
||||
"streamType": media.live ? "LIVE" : "BUFFERED",
|
||||
"metadata": ["metadataType": 0, "title": String(title.prefix(1024))],
|
||||
"customData": ["terebiSession": generation.uuidString]
|
||||
]
|
||||
if media.duration > 0, !media.live { info["duration"] = media.duration }
|
||||
if !media.tracks.isEmpty {
|
||||
info["tracks"] = media.tracks.map { track -> [String: Any] in
|
||||
["trackId": track.id, "type": "TEXT", "subtype": "SUBTITLES",
|
||||
"trackContentId": track.url.absoluteString, "trackContentType": "text/vtt",
|
||||
"name": track.label, "language": track.language]
|
||||
}
|
||||
}
|
||||
var command: [String: Any] = ["type": "LOAD", "media": info, "autoplay": playing,
|
||||
"activeTrackIds": subtitle.map { [media.tracks[$0].id] } ?? []]
|
||||
if position > 0 || !media.live { command["currentTime"] = position }
|
||||
let requestID = allocateRequest()
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
do {
|
||||
try request(command, namespace: CastWire.media, destination: transport,
|
||||
operation: "media load", generation: generation, timeout: loadTimeout,
|
||||
id: requestID) { [weak self] _, error in
|
||||
if let self, self.mediaGeneration == generation { self.loading = false; self.publishSeekState() }
|
||||
if let error { continuation.resume(throwing: error) }
|
||||
else { continuation.resume() }
|
||||
}
|
||||
} catch { loading = false; continuation.resume(throwing: error) }
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.mediaGeneration == generation else { return }
|
||||
// A cancelled LOAD may already be on the receiver. Tear down the channel
|
||||
// rather than letting its late acknowledgement acquire ownership.
|
||||
self.fail(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaying(_ playing: Bool) -> Bool {
|
||||
if replayTask != nil { replayDesiredPlaying = playing; return true }
|
||||
guard playing, latestStatus?.ended == true, let loadedMedia else {
|
||||
return control(["type": playing ? "PLAY" : "PAUSE"])
|
||||
}
|
||||
let subtitle = latestStatus?.subtitle
|
||||
let title = loadedTitle
|
||||
replayDesiredPlaying = true
|
||||
let connectionID = self.connectionID
|
||||
replayTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.load(loadedMedia, title: title, position: 0, playing: true, subtitle: subtitle)
|
||||
guard self.connectionID == connectionID else { return }
|
||||
self.replayTask = nil
|
||||
self.publishSeekState()
|
||||
if !self.replayDesiredPlaying { _ = self.setPlaying(false) }
|
||||
} catch {
|
||||
guard self.connectionID == connectionID else { return }
|
||||
self.replayTask = nil
|
||||
self.publishSeekState()
|
||||
if self.connection != nil { self.fail(error as? CastError ?? .disconnected) }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func seek(_ seconds: Double) -> Bool {
|
||||
guard seconds.isFinite, seconds >= 0, latestStatus?.seekable == true else { return false }
|
||||
return control(["type": "SEEK", "currentTime": seconds], seeking: true)
|
||||
}
|
||||
func setVolume(_ volume: Double) -> Bool {
|
||||
guard volume.isFinite, (0...1).contains(volume), appTransport != nil else { return false }
|
||||
return control(["type": "SET_VOLUME", "volume": ["level": volume, "muted": volume == 0]], receiver: true)
|
||||
}
|
||||
func setSubtitle(_ index: Int?) -> Bool {
|
||||
guard index == nil || tracks.indices.contains(index!) else { return false }
|
||||
return control(["type": "EDIT_TRACKS_INFO", "activeTrackIds": index.map { [tracks[$0].id] } ?? []])
|
||||
}
|
||||
|
||||
private func control(_ command: [String: Any], seeking: Bool = false, receiver: Bool = false) -> Bool {
|
||||
guard !loading, let transport = appTransport, let mediaSession else { return false }
|
||||
var command = command
|
||||
if !receiver { command["mediaSessionId"] = mediaSession }
|
||||
let id = allocateRequest()
|
||||
let type = command["type"] as? String
|
||||
let changingPlayback = type == "PLAY" || type == "PAUSE"
|
||||
do {
|
||||
try request(command, namespace: receiver ? CastWire.receiver : CastWire.media,
|
||||
destination: receiver ? "receiver-0" : transport,
|
||||
operation: (command["type"] as? String ?? "command").lowercased(),
|
||||
generation: mediaGeneration, id: id) { [weak self] _, error in
|
||||
guard let self else { return }
|
||||
if self.pendingVolume?.request == id { self.pendingVolume = nil; self.publishSeekState() }
|
||||
if case .cancelled? = error { return }
|
||||
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
|
||||
if self.pendingPlayback == id { self.pendingPlayback = nil; self.publishSeekState() }
|
||||
if let error { self.onError?(error.localizedDescription) }
|
||||
}
|
||||
if seeking { pendingSeek = id; publishSeekState() }
|
||||
if changingPlayback { pendingPlayback = id; publishSeekState() }
|
||||
if type == "SET_VOLUME", let level = (command["volume"] as? [String: Any])?["level"] as? Double {
|
||||
latestVolumeRequest = id
|
||||
pendingVolume = (id, level)
|
||||
publishSeekState()
|
||||
}
|
||||
return true
|
||||
} catch { onError?(error.localizedDescription); return false }
|
||||
}
|
||||
|
||||
private func allocateRequest() -> Int { nextRequest += 1; return nextRequest }
|
||||
|
||||
@discardableResult
|
||||
private func request(_ command: [String: Any], namespace: String, destination: String,
|
||||
operation: String, generation: UUID? = nil, timeout: Duration? = nil,
|
||||
id suppliedID: Int? = nil,
|
||||
completion: @escaping ([String: Any]?, CastError?) -> Void) throws -> Int {
|
||||
guard pending.count < 32 else { throw CastError.queueFull }
|
||||
let id = suppliedID ?? allocateRequest()
|
||||
var command = command
|
||||
command["requestId"] = id
|
||||
let sleep = self.sleep
|
||||
let duration = timeout ?? requestTimeout
|
||||
let deadline = Task { [weak self] in
|
||||
do { try await sleep(duration) } catch { return }
|
||||
self?.complete(id, json: nil, error: .timeout(operation))
|
||||
}
|
||||
pending[id] = Pending(namespace: namespace, destination: destination, operation: operation,
|
||||
generation: generation, completion: completion, deadline: deadline)
|
||||
do { try send(command, namespace: namespace, destination: destination) }
|
||||
catch { pending.removeValue(forKey: id)?.deadline.cancel(); throw error }
|
||||
return id
|
||||
}
|
||||
|
||||
private func complete(_ id: Int, json: [String: Any]?, error: CastError?) {
|
||||
guard let item = pending.removeValue(forKey: id) else { return }
|
||||
item.deadline.cancel()
|
||||
item.completion(json, error)
|
||||
}
|
||||
|
||||
private func cancelMediaRequests() {
|
||||
let ids = pending.filter { $0.value.generation != nil }.map(\.key)
|
||||
for id in ids { complete(id, json: nil, error: .cancelled) }
|
||||
}
|
||||
|
||||
private func send(_ json: [String: Any], namespace: String, destination: String) throws {
|
||||
guard connection != nil else { throw CastError.disconnected }
|
||||
guard writes.count < 32 else { throw CastError.queueFull }
|
||||
let payload = try JSONSerialization.data(withJSONObject: json)
|
||||
writes.append(try CastWire.frame(.init(source: source, destination: destination,
|
||||
namespace: namespace, payload: payload)))
|
||||
flush()
|
||||
}
|
||||
|
||||
private func flush() {
|
||||
guard !writing, let socket = connection, !writes.isEmpty else { return }
|
||||
writing = true
|
||||
let data = writes.removeFirst()
|
||||
let id = connectionID
|
||||
socket.send(content: data, completion: .contentProcessed { [weak self] error in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
self.writing = false
|
||||
if error != nil { self.fail(.disconnected) } else { self.flush() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func receive(_ socket: NWConnection, id: UUID) {
|
||||
socket.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, ended, error in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
do {
|
||||
if let data {
|
||||
for message in try self.stream.append(data) {
|
||||
self.handle(message)
|
||||
guard self.connectionID == id else { return }
|
||||
}
|
||||
}
|
||||
if ended || error != nil { self.fail(.disconnected) }
|
||||
else { self.receive(socket, id: id) }
|
||||
} catch { self.fail(.protocolError) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ message: CastWire.Message) {
|
||||
guard message.destination == source || message.destination == "*",
|
||||
let json = try? JSONSerialization.jsonObject(with: message.payload) as? [String: Any],
|
||||
let type = json["type"] as? String else { return }
|
||||
if message.namespace == CastWire.heartbeat, message.source == "receiver-0" {
|
||||
if type == "PONG" { awaitingPong = false }
|
||||
if type == "PING" {
|
||||
do { try send(["type": "PONG"], namespace: CastWire.heartbeat, destination: "receiver-0") }
|
||||
catch { fail(.disconnected) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if message.namespace == CastWire.connection, type == "CLOSE",
|
||||
message.source == appTransport || message.source == "receiver-0" {
|
||||
fail(.disconnected); return
|
||||
}
|
||||
let id = json["requestId"] as? Int
|
||||
let item = id.flatMap { pending[$0] }
|
||||
let correlated = item?.namespace == message.namespace && item?.destination == message.source &&
|
||||
(item?.generation == nil || item?.generation == mediaGeneration)
|
||||
if ["INVALID_REQUEST", "INVALID_PLAYER_STATE", "LOAD_FAILED", "LOAD_CANCELLED", "LAUNCH_ERROR"].contains(type) {
|
||||
if correlated, let id, let item { complete(id, json: nil, error: .rejected(item.operation)) }
|
||||
return
|
||||
}
|
||||
if message.namespace == CastWire.receiver, message.source == "receiver-0", type == "RECEIVER_STATUS" {
|
||||
if let status = json["status"] as? [String: Any] {
|
||||
let olderVolumeReply = id.map { $0 > 0 && $0 < latestVolumeRequest } ?? false
|
||||
let acknowledgesVolume = correlated && id == pendingVolume?.request
|
||||
if !olderVolumeReply, pendingVolume == nil || acknowledgesVolume,
|
||||
let volume = status["volume"] as? [String: Any],
|
||||
let level = volume["level"] as? Double, level.isFinite {
|
||||
let muted = volume["muted"] as? Bool ?? false
|
||||
receiverVolume = muted ? 0 : min(1, max(0, level))
|
||||
if acknowledgesVolume { pendingVolume = nil }
|
||||
if let latestStatus { publish(latestStatus) }
|
||||
}
|
||||
// Receiver readiness can be broadcast before/without the LAUNCH reply.
|
||||
// Only the requested Default Media Receiver on receiver-0 can satisfy it.
|
||||
if let launchRequest, let apps = status["applications"] as? [[String: Any]],
|
||||
let app = apps.first(where: { $0["appId"] as? String == "CC1AD845" }),
|
||||
let transport = app["transportId"] as? String, !transport.isEmpty,
|
||||
let session = app["sessionId"] as? String, !session.isEmpty {
|
||||
appTransport = transport
|
||||
appSession = session
|
||||
do {
|
||||
try send(["type": "CONNECT", "origin": [:]], namespace: CastWire.connection, destination: transport)
|
||||
} catch { fail(error as? CastError ?? .protocolError); return }
|
||||
self.launchRequest = nil
|
||||
complete(launchRequest, json: json, error: nil)
|
||||
let ready = self.ready
|
||||
self.ready = nil
|
||||
ready?.resume()
|
||||
}
|
||||
if let appSession, let appTransport {
|
||||
let apps = status["applications"] as? [[String: Any]] ?? []
|
||||
guard apps.contains(where: { $0["appId"] as? String == "CC1AD845" &&
|
||||
$0["sessionId"] as? String == appSession &&
|
||||
$0["transportId"] as? String == appTransport }) else {
|
||||
fail(.rejected("receiver ownership changed")); return
|
||||
}
|
||||
}
|
||||
}
|
||||
if correlated, let id, id != launchRequest, id != pendingVolume?.request {
|
||||
complete(id, json: json, error: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard message.namespace == CastWire.media, message.source == appTransport,
|
||||
type == "MEDIA_STATUS", let statuses = json["status"] as? [[String: Any]] else { return }
|
||||
let olderReply = correlated && id.map { $0 < newestMediaReply } == true
|
||||
if olderReply, id != pendingSeek, id != pendingPlayback, let id {
|
||||
complete(id, json: json, error: nil)
|
||||
return
|
||||
}
|
||||
for status in statuses {
|
||||
guard let session = status["mediaSessionId"] as? Int else { continue }
|
||||
let media = status["media"] as? [String: Any]
|
||||
let reportedContent = media?["contentId"] as? String
|
||||
let customData = media?["customData"] as? [String: Any]
|
||||
if let nonce = customData?["terebiSession"] as? String, nonce != mediaGeneration?.uuidString {
|
||||
if retiredLoads.contains(nonce) { continue }
|
||||
if !loading { fail(.rejected("media ownership changed")); return }
|
||||
continue
|
||||
}
|
||||
if mediaSession == nil {
|
||||
guard loading, correlated, item?.operation == "media load", reportedContent == contentID,
|
||||
!retiredSessions.contains(session) else { continue }
|
||||
mediaSession = session
|
||||
}
|
||||
guard session == mediaSession else {
|
||||
if !retiredSessions.contains(session), !loading {
|
||||
fail(.rejected("media ownership changed")); return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if let reportedContent, reportedContent != contentID {
|
||||
fail(.rejected("media ownership changed")); return
|
||||
}
|
||||
let state = status["playerState"] as? String ?? "IDLE"
|
||||
guard ["IDLE", "PLAYING", "PAUSED", "BUFFERING"].contains(state) else {
|
||||
fail(.protocolError); return
|
||||
}
|
||||
let idle = status["idleReason"] as? String
|
||||
if state == "IDLE", idle == "ERROR" {
|
||||
if correlated, let id { complete(id, json: nil, error: .rejected("media playback")) }
|
||||
fail(.rejected("media playback")); return
|
||||
}
|
||||
if state == "IDLE", idle == "INTERRUPTED" || idle == "CANCELLED" {
|
||||
fail(.rejected("media playback stopped")); return
|
||||
}
|
||||
let receivedPosition = status["currentTime"] as? Double ?? latestStatus?.position ?? 0
|
||||
let position = olderReply && id != pendingSeek ? latestStatus?.position ?? receivedPosition : receivedPosition
|
||||
let duration = media?["duration"] as? Double ?? latestStatus?.duration ?? loadedMedia?.duration ?? 0
|
||||
guard position.isFinite, duration.isFinite else { fail(.protocolError); return }
|
||||
let supported = status["supportedMediaCommands"] as? Int
|
||||
let active = status["activeTrackIds"] as? [Int]
|
||||
let subtitle = active.map { ids in tracks.firstIndex { ids.contains($0.id) } } ?? latestStatus?.subtitle
|
||||
let live = media?["streamType"] as? String == "LIVE" || loadedMedia?.live == true
|
||||
// A late SEEK still owns its position acknowledgement, but not newer playback intent.
|
||||
let previous = olderReply && id != pendingPlayback ? latestStatus : nil
|
||||
// An old status/poll cannot clear a newer seek. Only that seek's reply does.
|
||||
if correlated, id == pendingSeek { pendingSeek = nil }
|
||||
if correlated, id == pendingPlayback { pendingPlayback = nil }
|
||||
let next = CastStatus(position: max(0, position), duration: max(0, duration),
|
||||
playing: previous?.playing ?? (state == "PLAYING" || state == "BUFFERING"),
|
||||
buffering: previous?.buffering ?? (state == "BUFFERING"), ended: previous?.ended ?? (state == "IDLE" && idle == "FINISHED"),
|
||||
volume: pendingVolume?.level ?? receiverVolume,
|
||||
seekable: !live && (supported.map { $0 & 2 != 0 } ?? latestStatus?.seekable ?? false),
|
||||
subtitle: subtitle, seekPending: pendingSeek != nil, playbackPending: playbackPending)
|
||||
latestStatus = next
|
||||
if correlated, let id { newestMediaReply = max(newestMediaReply, id) }
|
||||
onStatus?(next)
|
||||
if correlated, let id { complete(id, json: json, error: nil) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ status: CastStatus) {
|
||||
let next = CastStatus(position: status.position, duration: status.duration, playing: status.playing,
|
||||
buffering: status.buffering, ended: status.ended, volume: pendingVolume?.level ?? receiverVolume,
|
||||
seekable: status.seekable, subtitle: status.subtitle, seekPending: pendingSeek != nil,
|
||||
playbackPending: playbackPending)
|
||||
latestStatus = next
|
||||
onStatus?(next)
|
||||
}
|
||||
private func publishSeekState() { if let latestStatus { publish(latestStatus) } }
|
||||
|
||||
private func startHeartbeat() {
|
||||
heartbeatTask?.cancel()
|
||||
let id = connectionID
|
||||
heartbeatTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self, self.connectionID == id else { return }
|
||||
do { try await self.sleep(self.heartbeatInterval) } catch { return }
|
||||
guard self.connectionID == id else { return }
|
||||
if self.awaitingPong { self.fail(.timeout("heartbeat")); return }
|
||||
self.awaitingPong = true
|
||||
do {
|
||||
try self.send(["type": "PING"], namespace: CastWire.heartbeat, destination: "receiver-0")
|
||||
// Receivers do not reliably broadcast clock progress. Poll without
|
||||
// extrapolating receiver time, allowing only one outstanding poll.
|
||||
if !self.loading, let transport = self.appTransport, let session = self.mediaSession,
|
||||
self.pending[self.lastStatusPoll] == nil {
|
||||
self.lastStatusPoll = try self.request(["type": "GET_STATUS", "mediaSessionId": session],
|
||||
namespace: CastWire.media, destination: transport, operation: "media status",
|
||||
generation: self.mediaGeneration) { [weak self] _, error in
|
||||
if case .cancelled? = error { return }
|
||||
if let error { self?.onError?(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
} catch { self.fail(error as? CastError ?? .disconnected); return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(stopReceiver: Bool = true) { teardown(error: nil, stopReceiver: stopReceiver) }
|
||||
private func fail(_ error: CastError) { teardown(error: error, stopReceiver: false) }
|
||||
|
||||
private func teardown(error: CastError?, stopReceiver: Bool) {
|
||||
let socket = connection
|
||||
let connected = socket != nil
|
||||
var farewell = Data()
|
||||
if connected {
|
||||
func append(_ json: [String: Any], namespace: String, destination: String) {
|
||||
if let payload = try? JSONSerialization.data(withJSONObject: json),
|
||||
let frame = try? CastWire.frame(.init(source: source, destination: destination,
|
||||
namespace: namespace, payload: payload)) { farewell.append(frame) }
|
||||
}
|
||||
// Stop only the media session we acquired, never STOP the application: another
|
||||
// sender may be using the Default Media Receiver by the time we disconnect.
|
||||
if stopReceiver, let mediaSession, let appTransport, !loading {
|
||||
append(["type": "STOP", "mediaSessionId": mediaSession, "requestId": allocateRequest()],
|
||||
namespace: CastWire.media, destination: appTransport)
|
||||
}
|
||||
if let appTransport { append(["type": "CLOSE"], namespace: CastWire.connection, destination: appTransport) }
|
||||
append(["type": "CLOSE"], namespace: CastWire.connection, destination: "receiver-0")
|
||||
}
|
||||
connectionID = UUID()
|
||||
connection = nil
|
||||
socket?.stateUpdateHandler = nil
|
||||
readyDeadline?.cancel(); readyDeadline = nil
|
||||
heartbeatTask?.cancel(); heartbeatTask = nil
|
||||
replayTask?.cancel(); replayTask = nil
|
||||
awaitingPong = false
|
||||
let ready = self.ready
|
||||
self.ready = nil
|
||||
let requests = pending.values
|
||||
pending.removeAll()
|
||||
writes.removeAll(); writing = false
|
||||
appTransport = nil; appSession = nil; mediaSession = nil; launchRequest = nil
|
||||
retiredSessions.removeAll(); contentID = nil; mediaGeneration = nil
|
||||
retiredLoads.removeAll()
|
||||
tracks.removeAll(); latestStatus = nil; pendingSeek = nil; loading = false
|
||||
pendingPlayback = nil
|
||||
loadedMedia = nil; loadedTitle = ""
|
||||
receiverVolume = 1
|
||||
pendingVolume = nil; latestVolumeRequest = 0
|
||||
ready?.resume(throwing: error ?? CastError.cancelled)
|
||||
for request in requests {
|
||||
request.deadline.cancel()
|
||||
request.completion(nil, error ?? .cancelled)
|
||||
}
|
||||
if let socket {
|
||||
if !farewell.isEmpty, error == nil {
|
||||
socket.send(content: farewell, completion: .contentProcessed { _ in socket.cancel() })
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { socket.cancel() }
|
||||
} else { socket.cancel() }
|
||||
}
|
||||
if connected { onDisconnect?(error?.localizedDescription) }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
|
||||
@MainActor
|
||||
final class CompactWindowController: NSObject {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var isCompact = false
|
||||
private(set) var isTransitioning = false
|
||||
static let transitionDuration: TimeInterval = 0.25
|
||||
|
||||
var snappingEnabled: Bool {
|
||||
didSet {
|
||||
@@ -54,9 +56,17 @@ final class CompactWindowController: NSObject {
|
||||
private var originatedFullScreen = false
|
||||
private var videoAspect = 16.0 / 9.0
|
||||
private var fittedAspect: Double?
|
||||
private let reduceMotion: () -> Bool
|
||||
private let defaults: UserDefaults
|
||||
private static let widthKey = "compactWindowWidth"
|
||||
private var frameAnimating = false
|
||||
private var frameAnimationGeneration: UInt64 = 0
|
||||
|
||||
init(snappingEnabled: Bool = true) {
|
||||
init(snappingEnabled: Bool = true, defaults: UserDefaults = .standard,
|
||||
reduceMotion: @escaping () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }) {
|
||||
self.snappingEnabled = snappingEnabled
|
||||
self.reduceMotion = reduceMotion
|
||||
self.defaults = defaults
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -64,6 +74,7 @@ final class CompactWindowController: NSObject {
|
||||
|
||||
func attach(_ window: NSWindow) {
|
||||
guard window !== self.window else { return }
|
||||
stopFrameAnimation()
|
||||
if let oldWindow = self.window, fullScreenTransition == nil,
|
||||
!oldWindow.styleMask.contains(.fullScreen) {
|
||||
restoreSnapshot(to: oldWindow)
|
||||
@@ -83,7 +94,7 @@ final class CompactWindowController: NSObject {
|
||||
window.delegate = proxy
|
||||
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification,
|
||||
NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification,
|
||||
NSWindow.willCloseNotification] {
|
||||
NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
|
||||
name: name, object: window)
|
||||
}
|
||||
@@ -94,22 +105,24 @@ final class CompactWindowController: NSObject {
|
||||
restore()
|
||||
return
|
||||
}
|
||||
guard let window, aspect.isFinite, aspect > 0 else { return }
|
||||
guard let window else { return }
|
||||
if !sessionActive {
|
||||
sessionActive = true
|
||||
originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil
|
||||
}
|
||||
videoAspect = aspect
|
||||
// Keep the last usable ratio until loading supplies the video's metadata.
|
||||
if aspect.isFinite && aspect > 0 { videoAspect = aspect }
|
||||
wantsCompact = true
|
||||
wantsFullScreen = false
|
||||
reconcile()
|
||||
}
|
||||
|
||||
func restore(resumeFullScreen: Bool = true) {
|
||||
func restore(resumeFullScreen: Bool = true, animated: Bool = true) {
|
||||
guard sessionActive else { return }
|
||||
wantsCompact = false
|
||||
wantsFullScreen = resumeFullScreen && originatedFullScreen
|
||||
reconcile()
|
||||
if !animated { stopFrameAnimation() }
|
||||
reconcile(animated: animated)
|
||||
}
|
||||
|
||||
func updateAspect(_ aspect: Double) {
|
||||
@@ -127,6 +140,10 @@ final class CompactWindowController: NSObject {
|
||||
|
||||
@objc private func windowNotification(_ notification: Notification) {
|
||||
switch notification.name {
|
||||
case NSWindow.didEndLiveResizeNotification:
|
||||
guard isCompact, !isTransitioning, let window,
|
||||
window.frame.width.isFinite, window.frame.width > 0 else { return }
|
||||
defaults.set(Double(window.frame.width), forKey: Self.widthKey)
|
||||
case NSWindow.willEnterFullScreenNotification:
|
||||
fullScreenTransition = .entering
|
||||
setState(compact: isCompact, transitioning: true)
|
||||
@@ -137,6 +154,7 @@ final class CompactWindowController: NSObject {
|
||||
fullScreenTransition = nil
|
||||
reconcile()
|
||||
case NSWindow.willCloseNotification:
|
||||
stopFrameAnimation()
|
||||
restoreDelegate()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
window = nil
|
||||
@@ -204,8 +222,8 @@ final class CompactWindowController: NSObject {
|
||||
}
|
||||
|
||||
// A toggle changes the destination, never reverses an in-flight native animation.
|
||||
private func reconcile() {
|
||||
guard let window, fullScreenTransition == nil else { return }
|
||||
private func reconcile(animated: Bool = true) {
|
||||
guard let window, fullScreenTransition == nil, !frameAnimating else { return }
|
||||
guard sessionActive else {
|
||||
setState(compact: isCompact, transitioning: false)
|
||||
return
|
||||
@@ -214,7 +232,7 @@ final class CompactWindowController: NSObject {
|
||||
if window.styleMask.contains(.fullScreen) {
|
||||
toggleNativeFullScreen(window, transition: .exiting)
|
||||
} else if !isCompact {
|
||||
enterCompact(window)
|
||||
enterCompact(window, animated: animated)
|
||||
} else {
|
||||
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
|
||||
setState(compact: true, transitioning: false)
|
||||
@@ -229,10 +247,17 @@ final class CompactWindowController: NSObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
if snapshot != nil {
|
||||
setState(compact: isCompact, transitioning: true)
|
||||
restoreSnapshot(to: window)
|
||||
setState(compact: false, transitioning: wantsFullScreen)
|
||||
if let snapshot {
|
||||
setState(compact: false, transitioning: animated)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
window.contentMinSize = .zero
|
||||
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
self.restoreSnapshot(to: window)
|
||||
self.reconcile()
|
||||
}
|
||||
return
|
||||
}
|
||||
if wantsFullScreen {
|
||||
toggleNativeFullScreen(window, transition: .entering)
|
||||
@@ -247,10 +272,10 @@ final class CompactWindowController: NSObject {
|
||||
window.toggleFullScreen(nil)
|
||||
}
|
||||
|
||||
private func enterCompact(_ window: NSWindow) {
|
||||
setState(compact: false, transitioning: true)
|
||||
private func enterCompact(_ window: NSWindow, animated: Bool) {
|
||||
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
||||
snapshot = Snapshot(window)
|
||||
setState(compact: true, transitioning: animated)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
window.contentMinSize = .zero
|
||||
@@ -267,8 +292,7 @@ final class CompactWindowController: NSObject {
|
||||
window.level = .floating
|
||||
window.hidesOnDeactivate = false
|
||||
window.isMovableByWindowBackground = true
|
||||
fitCompactWindow(window, initial: true)
|
||||
setState(compact: true, transitioning: false)
|
||||
fitCompactWindow(window, initial: true, animated: animated)
|
||||
}
|
||||
|
||||
private func restoreSnapshot(to window: NSWindow) {
|
||||
@@ -298,6 +322,42 @@ final class CompactWindowController: NSObject {
|
||||
setState(compact: false, transitioning: false)
|
||||
}
|
||||
|
||||
private func animateFrame(_ frame: NSRect, of window: NSWindow, animated: Bool,
|
||||
completion: @escaping @MainActor () -> Void) {
|
||||
guard animated, !reduceMotion(), window.frame != frame else {
|
||||
window.setFrame(frame, display: true)
|
||||
completion()
|
||||
return
|
||||
}
|
||||
frameAnimationGeneration &+= 1
|
||||
let generation = frameAnimationGeneration
|
||||
frameAnimating = true
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = Self.transitionDuration
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
} completionHandler: { [weak self, weak window] in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let window, self.window === window, self.frameAnimating,
|
||||
self.frameAnimationGeneration == generation else { return }
|
||||
self.frameAnimating = false
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopFrameAnimation() {
|
||||
guard frameAnimating else { return }
|
||||
frameAnimating = false
|
||||
frameAnimationGeneration &+= 1
|
||||
guard let window else { return }
|
||||
// A zero-duration animator update cancels the native in-flight frame animation.
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0
|
||||
window.animator().setFrame(window.frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func setState(compact: Bool, transitioning: Bool) {
|
||||
guard isCompact != compact || isTransitioning != transitioning else { return }
|
||||
isCompact = compact
|
||||
@@ -309,26 +369,31 @@ final class CompactWindowController: NSObject {
|
||||
(window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
||||
}
|
||||
|
||||
private func fitCompactWindow(_ window: NSWindow, initial: Bool) {
|
||||
private func fitCompactWindow(_ window: NSWindow, initial: Bool, animated: Bool = false) {
|
||||
let visibleFrame = visibleFrame(for: window)
|
||||
let usable = Self.usableFrame(visibleFrame)
|
||||
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
|
||||
let aspect = videoAspect
|
||||
let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
|
||||
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320
|
||||
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame, width: width)
|
||||
if !initial {
|
||||
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
|
||||
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
|
||||
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
|
||||
}
|
||||
window.aspectRatio = .zero
|
||||
window.setFrame(frame, display: true)
|
||||
if Self.canFitAspect(videoAspect, available: usable.size) {
|
||||
window.aspectRatio = NSSize(width: videoAspect, height: 1)
|
||||
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
if Self.canFitAspect(aspect, available: usable.size) {
|
||||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||
}
|
||||
self.fittedAspect = aspect
|
||||
if initial { self.reconcile() }
|
||||
}
|
||||
fittedAspect = videoAspect
|
||||
}
|
||||
|
||||
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect {
|
||||
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect, width: CGFloat) -> NSRect {
|
||||
let usable = usableFrame(visibleFrame)
|
||||
let size = compactSize(aspect: aspect, width: 320, available: usable.size)
|
||||
let size = compactSize(aspect: aspect, width: width, available: usable.size)
|
||||
return NSRect(x: usable.maxX - size.width, y: usable.maxY - size.height, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import Foundation
|
||||
|
||||
/// Immutable, reusable subtitles; times are acknowledged player positions, not wall-clock time.
|
||||
struct WebVTTDocument: Sendable {
|
||||
struct Cue: Sendable, Equatable {
|
||||
let start: Double
|
||||
let end: Double
|
||||
let text: String
|
||||
}
|
||||
|
||||
let cues: [Cue]
|
||||
private let maximumEnds: [Double]
|
||||
static let byteLimit = 4 * 1024 * 1024
|
||||
|
||||
private enum SubtitleError: LocalizedError {
|
||||
case request, transport, content
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .request: "Could not load subtitles. The subtitle URL or headers are invalid."
|
||||
case .transport: "Could not load subtitles. Check the connection or choose another subtitle track."
|
||||
case .content: "The subtitle resource is not valid WebVTT."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func load(url: String, headers: String) async throws -> WebVTTDocument {
|
||||
let data = try await loadData(url: url, headers: headers)
|
||||
// Providers sometimes disguise VTT resources as images: trust the explicit format and content.
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
|
||||
return try parse(text)
|
||||
}
|
||||
|
||||
static func loadData(url: String, headers: String) async throws -> Data {
|
||||
let request = try request(url: url, headers: headers)
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 20
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCredentialStorage = nil
|
||||
configuration.urlCache = nil
|
||||
let session = URLSession(configuration: configuration, delegate: SubtitleRedirects(request: request), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
let data: Data
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200...299).contains(response.statusCode), response.expectedContentLength <= Int64(byteLimit) else {
|
||||
throw SubtitleError.transport
|
||||
}
|
||||
var buffer = Data()
|
||||
if response.expectedContentLength > 0 { buffer.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
guard buffer.count < byteLimit else { throw SubtitleError.transport }
|
||||
buffer.append(byte)
|
||||
}
|
||||
data = buffer
|
||||
} catch {
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
throw SubtitleError.transport
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func parse(_ text: String) throws -> WebVTTDocument {
|
||||
guard text.utf8.count <= byteLimit, !text.contains("\0") else { throw SubtitleError.content }
|
||||
var normalized = text
|
||||
if normalized.first == "\u{FEFF}" { normalized.removeFirst() }
|
||||
normalized = normalized.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
||||
let lines = normalized.components(separatedBy: "\n")
|
||||
guard let header = lines.first,
|
||||
header == "WEBVTT" || header.hasPrefix("WEBVTT ") || header.hasPrefix("WEBVTT\t"),
|
||||
!header.contains("-->") else { throw SubtitleError.content }
|
||||
var index = 1
|
||||
// Header metadata ends at the first blank line. A cue without this separator is malformed.
|
||||
while index < lines.count && !lines[index].isEmpty {
|
||||
guard !lines[index].contains("-->") else { throw SubtitleError.content }
|
||||
index += 1
|
||||
}
|
||||
var cues: [Cue] = []
|
||||
var maximumEnds: [Double] = []
|
||||
while index < lines.count {
|
||||
while index < lines.count && lines[index].isEmpty { index += 1 }
|
||||
let beginning = index
|
||||
while index < lines.count && !lines[index].isEmpty { index += 1 }
|
||||
guard beginning < index else { break }
|
||||
let block = lines[beginning..<index]
|
||||
let first = lines[beginning]
|
||||
if first == "NOTE" || first.hasPrefix("NOTE ") || first.hasPrefix("NOTE\t") { continue }
|
||||
if first == "STYLE" || first == "REGION" {
|
||||
guard cues.isEmpty, !block.contains(where: { $0.contains("-->") }) else { throw SubtitleError.content }
|
||||
continue // ponytail: plain-text cues; add layout/styling when positional subtitles required.
|
||||
}
|
||||
var timingIndex = beginning
|
||||
if !first.contains("-->") { timingIndex += 1 } // Optional cue identifier.
|
||||
guard timingIndex < index else { throw SubtitleError.content }
|
||||
let timing = lines[timingIndex].split(whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard timing.count >= 3, timing[1] == "-->",
|
||||
let start = timestamp(String(timing[0])), let end = timestamp(String(timing[2])),
|
||||
end >= start, start >= (cues.last?.start ?? 0) else { throw SubtitleError.content }
|
||||
// Equal timestamps produce an inactive cue, not a failure of the entire track.
|
||||
try validateSettings(timing.dropFirst(3))
|
||||
let payload = lines[(timingIndex + 1)..<index].joined(separator: "\n")
|
||||
guard !payload.contains("-->") else { throw SubtitleError.content }
|
||||
let cue = Cue(start: start, end: end, text: try plainText(payload))
|
||||
cues.append(cue)
|
||||
maximumEnds.append(max(end, maximumEnds.last ?? 0))
|
||||
}
|
||||
return WebVTTDocument(cues: cues, maximumEnds: maximumEnds)
|
||||
}
|
||||
|
||||
func text(at acknowledgedSeconds: Double) -> String {
|
||||
guard acknowledgedSeconds.isFinite, acknowledgedSeconds >= 0 else { return "" }
|
||||
// Binary-search starts, then inspect only the potentially overlapping interval. No cursor:
|
||||
// backward seeks and switching to an already-loaded track need no reset.
|
||||
var low = 0
|
||||
var high = cues.count
|
||||
while low < high {
|
||||
let middle = low + (high - low) / 2
|
||||
if cues[middle].start <= acknowledgedSeconds { low = middle + 1 } else { high = middle }
|
||||
}
|
||||
var active: [String] = []
|
||||
var index = low
|
||||
while index > 0 {
|
||||
index -= 1
|
||||
if maximumEnds[index] <= acknowledgedSeconds { break }
|
||||
if cues[index].end > acknowledgedSeconds && !cues[index].text.isEmpty { active.append(cues[index].text) }
|
||||
}
|
||||
return active.reversed().joined(separator: "\n")
|
||||
}
|
||||
|
||||
// Shared with the focused check: these are the exact request and redirect policies used above.
|
||||
static func request(url text: String, headers: String) throws -> URLRequest {
|
||||
guard text.utf8.count <= 32768,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
["http", "https"].contains(components.scheme?.lowercased() ?? ""),
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil, components.fragment == nil,
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let url = components.url, headers.utf8.count <= 65536,
|
||||
headers.isEmpty || headers.hasSuffix("\r\n") else { throw SubtitleError.request }
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
request.httpShouldHandleCookies = false
|
||||
var names = Set<String>()
|
||||
let lines = headers.isEmpty ? [] : headers.components(separatedBy: "\r\n").dropLast().map { $0 }
|
||||
guard lines.count <= 64 else { throw SubtitleError.request }
|
||||
for line in lines {
|
||||
guard let colon = line.firstIndex(of: ":") else { throw SubtitleError.request }
|
||||
let name = String(line[..<colon])
|
||||
var value = String(line[line.index(after: colon)...])
|
||||
// ResolvedSource serializes exactly one separator space; the resource value is verbatim.
|
||||
if value.first == " " { value.removeFirst() }
|
||||
guard !name.isEmpty, name.utf8.count <= 256,
|
||||
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
|
||||
names.insert(name.lowercased()).inserted, value.utf8.count <= 16384,
|
||||
!value.unicodeScalars.contains(where: { ($0.value < 32 && $0.value != 9) || $0.value == 127 }) else { throw SubtitleError.request }
|
||||
request.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
static func permitsRedirect(from source: URL, to destination: URL) -> Bool {
|
||||
guard let a = URLComponents(url: source, resolvingAgainstBaseURL: false),
|
||||
let b = URLComponents(url: destination, resolvingAgainstBaseURL: false),
|
||||
let scheme = a.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
b.scheme?.lowercased() == scheme, a.host?.lowercased() == b.host?.lowercased(),
|
||||
(a.port ?? (scheme == "https" ? 443 : 80)) == (b.port ?? (scheme == "https" ? 443 : 80)),
|
||||
b.user == nil, b.password == nil, b.fragment == nil else { return false }
|
||||
// Reject rather than guess which resource-specific headers contain credentials.
|
||||
return true
|
||||
}
|
||||
|
||||
private static func timestamp(_ text: String) -> Double? {
|
||||
let fields = text.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2 || fields.count == 3 else { return nil }
|
||||
let seconds = fields.last!.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard seconds.count == 2, seconds[0].utf8.count == 2, seconds[1].utf8.count == 3,
|
||||
fields.dropLast().allSatisfy({ $0.utf8.count >= 2 }),
|
||||
(fields.dropLast().map(String.init) + seconds.map(String.init)).allSatisfy({ $0.utf8.allSatisfy { (48...57).contains($0) } }),
|
||||
let second = Double(seconds[0]), second < 60,
|
||||
let milliseconds = Double(seconds[1]), let minute = Double(fields[fields.count - 2]),
|
||||
minute < 60, fields[fields.count - 2].utf8.count == 2 else { return nil }
|
||||
let hours = fields.count == 3 ? Double(fields[0]) : 0
|
||||
guard let hours else { return nil }
|
||||
let result = hours * 3600 + minute * 60 + second + milliseconds / 1000
|
||||
return result.isFinite ? result : nil
|
||||
}
|
||||
|
||||
private static func validateSettings(_ settings: ArraySlice<Substring>) throws {
|
||||
func percent(_ text: Substring) -> Bool {
|
||||
guard text.last == "%" else { return false }
|
||||
let number = text.dropLast()
|
||||
guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) || $0 == 46 }),
|
||||
let value = Double(number) else { return false }
|
||||
return (0...100).contains(value)
|
||||
}
|
||||
var names = Set<Substring>()
|
||||
for setting in settings {
|
||||
let pair = setting.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard pair.count == 2, !pair[1].isEmpty, names.insert(pair[0]).inserted else { throw SubtitleError.content }
|
||||
let values = pair[1].split(separator: ",", omittingEmptySubsequences: false)
|
||||
let valid: Bool
|
||||
switch pair[0] {
|
||||
case "vertical": valid = values.count == 1 && ["rl", "lr"].contains(pair[1])
|
||||
case "align": valid = values.count == 1 && ["start", "center", "end", "left", "right"].contains(pair[1])
|
||||
case "size": valid = values.count == 1 && percent(pair[1])
|
||||
case "region": valid = values.count == 1
|
||||
case "position":
|
||||
valid = (values.count == 1 || values.count == 2) && percent(values[0]) && (values.count == 1 || ["line-left", "center", "line-right", "auto"].contains(values[1]))
|
||||
case "line":
|
||||
let number = values[0].first == "-" ? values[0].dropFirst() : values[0][...]
|
||||
let integer = !number.isEmpty && number.utf8.allSatisfy { (48...57).contains($0) }
|
||||
valid = (values.count == 1 || values.count == 2) && (values[0] == "auto" || percent(values[0]) || integer) && (values.count == 1 || ["start", "center", "end"].contains(values[1]))
|
||||
default: valid = false
|
||||
}
|
||||
guard valid else { throw SubtitleError.content }
|
||||
}
|
||||
}
|
||||
|
||||
private static func plainText(_ payload: String) throws -> String {
|
||||
var result = ""
|
||||
var index = payload.startIndex
|
||||
let entities: [String: String] = ["amp": "&", "lt": "<", "gt": ">", "nbsp": "\u{A0}", "lrm": "\u{200E}", "rlm": "\u{200F}", "quot": "\"", "apos": "'"]
|
||||
while index < payload.endIndex {
|
||||
if payload[index] == "<" {
|
||||
guard let end = payload[index...].firstIndex(of: ">") else { throw SubtitleError.content }
|
||||
var tag = String(payload[payload.index(after: index)..<end])
|
||||
if tag.first == "/" { tag.removeFirst() }
|
||||
let name = tag.prefix { $0 != " " && $0 != "\t" && $0 != "." }
|
||||
guard ["b", "i", "u", "c", "v", "lang", "ruby", "rt"].contains(String(name)) || timestamp(tag) != nil else { throw SubtitleError.content }
|
||||
index = payload.index(after: end)
|
||||
} else if payload[index] == "&", let end = payload[index...].firstIndex(of: ";"), payload.distance(from: index, to: end) <= 16 {
|
||||
let entity = String(payload[payload.index(after: index)..<end])
|
||||
var decoded = entities[entity]
|
||||
if entity.hasPrefix("#") {
|
||||
let hexadecimal = entity.hasPrefix("#x") || entity.hasPrefix("#X")
|
||||
let digits = entity.dropFirst(hexadecimal ? 2 : 1)
|
||||
if let number = UInt32(digits, radix: hexadecimal ? 16 : 10), number != 0, let scalar = UnicodeScalar(number) { decoded = String(scalar) }
|
||||
}
|
||||
if let decoded {
|
||||
result += decoded
|
||||
index = payload.index(after: end)
|
||||
} else {
|
||||
result.append(payload[index])
|
||||
index = payload.index(after: index)
|
||||
}
|
||||
} else {
|
||||
result.append(payload[index])
|
||||
index = payload.index(after: index)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private final class SubtitleRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
private let request: URLRequest
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
init(request: URLRequest) { self.request = request }
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
lock.lock()
|
||||
count += 1
|
||||
let withinLimit = count <= 5
|
||||
lock.unlock()
|
||||
guard withinLimit, let source = request.url, let destination = newRequest.url,
|
||||
WebVTTDocument.permitsRedirect(from: source, to: destination) else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
var redirected = newRequest
|
||||
redirected.httpShouldHandleCookies = false
|
||||
for (name, value) in request.allHTTPHeaderFields ?? [:] { redirected.setValue(value, forHTTPHeaderField: name) }
|
||||
completionHandler(redirected)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,11 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func performMenuAction(_ action: PlayerAction) {
|
||||
// Native Command-Quit remains available while a text field owns focus.
|
||||
if action == .quit, NSApp.currentEvent?.modifierFlags.contains(.command) == true {
|
||||
perform(action)
|
||||
return
|
||||
}
|
||||
if let event = NSApp.currentEvent, event.type == .keyDown {
|
||||
if event.isARepeat && !action.allowsRepeat { return }
|
||||
guard let chord = KeyChord.from(event),
|
||||
@@ -162,10 +167,11 @@ struct PlayerCommands: Commands {
|
||||
let bufferInfo = model.bufferInfoAction
|
||||
let snapping = model.snappingAction
|
||||
let automaticQuality = model.automaticQualityAction
|
||||
let quitKeys = model.keyBindings.shortcuts(for: .quit)
|
||||
|
||||
CommandGroup(replacing: .appTermination) {
|
||||
Button("Quit terebi") { model.performMenuAction(.quit) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .quit).first?.keyboardShortcut)
|
||||
.keyboardShortcut((quitKeys.first { $0.modifiers.contains(.command) } ?? quitKeys.first)?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.quit))
|
||||
}
|
||||
CommandGroup(replacing: .newItem) {
|
||||
@@ -184,6 +190,8 @@ struct PlayerCommands: Commands {
|
||||
.disabled(focusedModel == nil)
|
||||
}
|
||||
CommandMenu("Playback") {
|
||||
CastDeviceMenu(model: model, icon: false)
|
||||
Divider()
|
||||
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(playback))
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
struct ResolvedSubtitle: Decodable, Sendable {
|
||||
let url: String
|
||||
let format: String
|
||||
let language: String
|
||||
let label: String
|
||||
let isDefault: Bool
|
||||
private let headerFields: [String: String]
|
||||
var headers: String {
|
||||
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
}
|
||||
fileprivate var validatedHeaders: [String: String] { headerFields }
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, format, language, label
|
||||
case isDefault = "default"
|
||||
case headerFields = "headers"
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedSource: Sendable {
|
||||
let url: String
|
||||
let headers: String
|
||||
let identity: String
|
||||
let title: String
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
|
||||
private struct Document: Decodable {
|
||||
struct Identity: Decodable {
|
||||
let provider: String
|
||||
let media_id: String
|
||||
let episode: Int
|
||||
}
|
||||
let version: Int
|
||||
let url: String
|
||||
let headers: [String: String]
|
||||
let identity: Identity
|
||||
let title: String?
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
}
|
||||
|
||||
private struct InvalidSource: LocalizedError {
|
||||
var errorDescription: String? { "Invalid or unreadable resolved source file." }
|
||||
}
|
||||
|
||||
static func load(from file: URL) throws -> ResolvedSource {
|
||||
do {
|
||||
guard file.isFileURL else { throw InvalidSource() }
|
||||
let descriptor: Int32 = file.withUnsafeFileSystemRepresentation { path in
|
||||
guard let path else { return -1 }
|
||||
return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC)
|
||||
}
|
||||
guard descriptor >= 0 else { throw InvalidSource() }
|
||||
var info = stat()
|
||||
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else {
|
||||
Darwin.close(descriptor)
|
||||
throw InvalidSource()
|
||||
}
|
||||
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
||||
defer { try? handle.close() }
|
||||
let data = try handle.read(upToCount: 1_048_577) ?? Data()
|
||||
guard data.count <= 1_048_576 else { throw InvalidSource() }
|
||||
let document = try JSONDecoder().decode(Document.self, from: data)
|
||||
let id = document.identity
|
||||
guard document.version == 1, id.provider == "miruro",
|
||||
!id.media_id.isEmpty, id.media_id.utf8.count <= 512,
|
||||
id.media_id.utf8.allSatisfy({ isIDByte($0) }),
|
||||
id.episode > 0, id.episode <= Int(Int32.max),
|
||||
validURL(document.url), validHeaders(document.headers),
|
||||
validText(document.title ?? "", limit: 4096),
|
||||
document.subtitles.count <= 32,
|
||||
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
|
||||
for subtitle in document.subtitles {
|
||||
guard validURL(subtitle.url), subtitle.format == "vtt",
|
||||
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
|
||||
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
|
||||
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
|
||||
}
|
||||
let headers = document.headers.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
return ResolvedSource(url: document.url, headers: headers,
|
||||
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
|
||||
subtitles: document.subtitles)
|
||||
} catch { throw InvalidSource() }
|
||||
}
|
||||
|
||||
private static func isIDByte(_ byte: UInt8) -> Bool {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
}
|
||||
|
||||
private static func validText(_ text: String, limit: Int) -> Bool {
|
||||
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
|
||||
}
|
||||
|
||||
private static func validURL(_ text: String) -> Bool {
|
||||
guard !text.isEmpty, validText(text, limit: 32768),
|
||||
!text.contains(where: { $0.isWhitespace }),
|
||||
let components = URLComponents(string: text),
|
||||
components.scheme == "https" || components.scheme == "http",
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil,
|
||||
components.fragment == nil, components.url != nil else { return false }
|
||||
return components.port == nil || (1...65535).contains(components.port!)
|
||||
}
|
||||
|
||||
private static func validHeaders(_ headers: [String: String]) -> Bool {
|
||||
guard headers.count <= 64 else { return false }
|
||||
var names = Set<String>()
|
||||
var size = 0
|
||||
for (name, value) in headers {
|
||||
guard !name.isEmpty, name.utf8.count <= 256,
|
||||
name.utf8.allSatisfy({ isIDByte($0) || "!#$%&'*+.^`|~".utf8.contains($0) }),
|
||||
names.insert(name.lowercased()).inserted,
|
||||
value.utf8.count <= 16384,
|
||||
!value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return false }
|
||||
size += name.utf8.count + value.utf8.count + 4
|
||||
}
|
||||
return size <= 65536
|
||||
}
|
||||
}
|
||||
+842
-99
File diff suppressed because it is too large
Load Diff
@@ -4,22 +4,25 @@ enum PlayerCommand {
|
||||
case help
|
||||
case printDefaultConfig
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
|
||||
static let usage = """
|
||||
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 --source-file PATH [--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.
|
||||
Open one local file, YouTube video or resolved source and start playback,
|
||||
resuming saved video progress. With no arguments, open or activate the player.
|
||||
Quote paths and URLs containing spaces or shell metacharacters.
|
||||
|
||||
--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.
|
||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||
--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 '-'.
|
||||
@@ -77,6 +80,19 @@ enum PlayerCommand {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
|
||||
return .printDefaultConfig
|
||||
}
|
||||
if inputs.first == "--source-file" {
|
||||
guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") }
|
||||
guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"),
|
||||
!inputs[1].hasPrefix("--") else {
|
||||
throw ArgumentError("--source-file requires exactly one filesystem path.")
|
||||
}
|
||||
let base = directory.hasDirectoryPath ? directory :
|
||||
URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
let sourceFile = URL(fileURLWithPath: inputs[1], relativeTo: base).absoluteURL.standardizedFileURL
|
||||
_ = try ResolvedSource.load(from: sourceFile)
|
||||
return .launchSource(sourceFile: sourceFile, newInstance: newInstance,
|
||||
noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
if inputs.first == "--url" {
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
||||
return try launch(YouTubeResolver.validateURL(inputs[1]))
|
||||
|
||||
@@ -18,6 +18,13 @@ enum TerebiCLI {
|
||||
case .printDefaultConfig:
|
||||
print(PlayerConfiguration.defaultTOML, terminator: "")
|
||||
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
||||
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
|
||||
await launch(url: nil, sourceFile: sourceFile, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
}
|
||||
|
||||
private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) async {
|
||||
if let progressFile {
|
||||
var existing = stat()
|
||||
guard lstat(progressFile.path, &existing) != 0 else {
|
||||
@@ -62,6 +69,9 @@ enum TerebiCLI {
|
||||
if let progressFile {
|
||||
configuration.arguments += ["--progress-file", progressFile.path]
|
||||
}
|
||||
if let sourceFile {
|
||||
configuration.arguments += ["--source-file", sourceFile.path]
|
||||
}
|
||||
if newInstance, let url {
|
||||
configuration.arguments += ["--open-media", url.absoluteString]
|
||||
}
|
||||
@@ -75,7 +85,6 @@ enum TerebiCLI {
|
||||
fail("Could not launch the player: \(error.localizedDescription)", code: 69)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func fail(_ message: String, code: Int32) -> Never {
|
||||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import Foundation
|
||||
import TOMLDecoder
|
||||
|
||||
public enum CompactTitleProgress: String, Decodable, Sendable {
|
||||
case time, percentage, none
|
||||
}
|
||||
|
||||
public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var showBufferInfo: Bool
|
||||
public var automaticQuality: Bool
|
||||
public var snapToEdges: Bool
|
||||
public var compactTitleProgress: CompactTitleProgress
|
||||
public var keys: KeyBindings
|
||||
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
||||
keys: KeyBindings = KeyBindings()) {
|
||||
compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) {
|
||||
self.showBufferInfo = showBufferInfo
|
||||
self.automaticQuality = automaticQuality
|
||||
self.snapToEdges = snapToEdges
|
||||
self.compactTitleProgress = compactTitleProgress
|
||||
self.keys = keys
|
||||
}
|
||||
|
||||
@@ -25,6 +31,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
"", "[ui]",
|
||||
"buffer_info = \(defaults.showBufferInfo)",
|
||||
"compact_snap_to_edges = \(defaults.snapToEdges)",
|
||||
"compact_title_progress = \"\(defaults.compactTitleProgress.rawValue)\"",
|
||||
"", "[keys]"
|
||||
]
|
||||
for (name, action) in PlayerAction.namedActions {
|
||||
@@ -77,6 +84,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
|
||||
} catch {
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
|
||||
@@ -163,11 +171,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
private struct UIConfiguration {
|
||||
let bufferInfo: Bool?
|
||||
let compactSnapToEdges: Bool?
|
||||
let compactTitleProgress: CompactTitleProgress?
|
||||
|
||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges"])
|
||||
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges", "compact_title_progress"])
|
||||
bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info"))
|
||||
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
|
||||
compactTitleProgress = try container.decodeIfPresent(CompactTitleProgress.self,
|
||||
forKey: SettingKey("compact_title_progress"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,876 @@
|
||||
#include "cast_media.h"
|
||||
#include "network_policy.hpp"
|
||||
#include "network_input.hpp"
|
||||
extern "C" {
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/audio_fifo.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libswresample/swresample.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <netdb.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
constexpr double segmentSeconds = 6;
|
||||
constexpr size_t segmentLimit = 32 * 1024 * 1024;
|
||||
constexpr size_t windowSegments = 8;
|
||||
void check(int value) { if (value < 0) throw std::runtime_error("Cast media operation failed (private details redacted)."); }
|
||||
void require(bool value) { if (!value) throw std::runtime_error("Cast media is unavailable or unsupported (private details redacted)."); }
|
||||
void privateLog(void *, int level, const char *, va_list) {
|
||||
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
|
||||
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg cast error (private details redacted)\n" :
|
||||
"FFmpeg cast warning (private details redacted)\n", stderr);
|
||||
}
|
||||
struct FrameDelete { void operator()(AVFrame *p) const { av_frame_free(&p); } };
|
||||
struct PacketDelete { void operator()(AVPacket *p) const { av_packet_free(&p); } };
|
||||
using Frame = std::unique_ptr<AVFrame, FrameDelete>;
|
||||
using Packet = std::unique_ptr<AVPacket, PacketDelete>;
|
||||
Frame frame() { auto p = Frame(av_frame_alloc()); require(bool(p)); return p; }
|
||||
Packet packet() { auto p = Packet(av_packet_alloc()); require(bool(p)); return p; }
|
||||
int64_t aacBoundary(double seconds) { return int64_t(std::ceil(seconds * 48000 / 1024)) * 1024; }
|
||||
double aacPreroll(double seconds) { return double(std::max<int64_t>(0, aacBoundary(seconds) - 12 * 1024)) / 48000; }
|
||||
bool hasIDR(const AVPacket &packet, const AVCodecParameters &codec) {
|
||||
const auto *bytes = packet.data;
|
||||
const size_t size = size_t(std::max(0, packet.size));
|
||||
if (codec.extradata_size >= 5 && codec.extradata[0] == 1) {
|
||||
const size_t lengthSize = (codec.extradata[4] & 3) + 1;
|
||||
for (size_t at = 0; at + lengthSize <= size;) {
|
||||
uint32_t length = 0;
|
||||
for (size_t i = 0; i < lengthSize; ++i) length = (length << 8) | bytes[at++];
|
||||
if (!length || length > size - at) return false;
|
||||
if ((bytes[at] & 31) == 5) return true;
|
||||
at += length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (size_t at = 0; at + 3 < size; ++at) {
|
||||
if (bytes[at] == 0 && bytes[at + 1] == 0 && bytes[at + 2] == 1 &&
|
||||
(bytes[at + 3] & 31) == 5) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
struct Source {
|
||||
std::string video, videoHeaders, audio, audioHeaders;
|
||||
bool local, restricted, live;
|
||||
double duration;
|
||||
};
|
||||
struct Input {
|
||||
AVFormatContext *format = nullptr;
|
||||
std::atomic<bool> &cancel;
|
||||
std::string origin, headers;
|
||||
std::atomic<int64_t> deadline{0};
|
||||
std::atomic<int64_t> operationDeadline{std::numeric_limits<int64_t>::max()};
|
||||
bool network, restricted, live;
|
||||
std::atomic<uint64_t> epoch{0};
|
||||
std::mutex waitLock;
|
||||
std::condition_variable wake;
|
||||
bool positioned = false;
|
||||
std::unique_ptr<playback::NetworkInput> buffered;
|
||||
Input(std::atomic<bool> &stop, const std::string &url, const std::string &fields, bool local, bool restricted, bool live) :
|
||||
cancel(stop), headers(fields), network(!local), restricted(restricted), live(live) {
|
||||
origin = local ? "" : playback::networkOrigin(url.c_str());
|
||||
format = avformat_alloc_context(); require(format != nullptr);
|
||||
format->interrupt_callback = {interrupt, this};
|
||||
if (network) { format->opaque = this; format->io_open = openResource; }
|
||||
AVDictionary *options = nullptr;
|
||||
auto set = [&](const char *key, const char *value) {
|
||||
int result = av_dict_set(&options, key, value, 0);
|
||||
if (result < 0) { av_dict_free(&options); avformat_close_input(&format); check(result); }
|
||||
};
|
||||
set("protocol_whitelist", local ? "file" : "http,https,tcp,tls,crypto");
|
||||
set("rw_timeout", live ? "15000000" : "5000000");
|
||||
set("probesize", "5242880");
|
||||
set("analyzeduration", "5000000");
|
||||
if (network) {
|
||||
if (!headers.empty()) set("headers", headers.c_str());
|
||||
set("max_redirects", "0");
|
||||
set("http_persistent", "0");
|
||||
set("http_multiple", "0");
|
||||
set("extension_picky", "0");
|
||||
set("request_size", "10485760");
|
||||
}
|
||||
arm();
|
||||
int result = avformat_open_input(&format, url.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
if (result >= 0) { arm(); result = avformat_find_stream_info(format, nullptr); }
|
||||
if (result < 0) { avformat_close_input(&format); check(result); }
|
||||
}
|
||||
~Input() {
|
||||
if (buffered) buffered->stop();
|
||||
else avformat_close_input(&format);
|
||||
}
|
||||
void arm() {
|
||||
const auto ioDeadline = std::chrono::duration_cast<std::chrono::nanoseconds>((Clock::now() + std::chrono::seconds(15)).time_since_epoch()).count();
|
||||
deadline = std::min<int64_t>(ioDeadline, operationDeadline.load());
|
||||
}
|
||||
bool interrupted() const {
|
||||
const auto now = std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now().time_since_epoch()).count();
|
||||
return cancel || now >= operationDeadline.load() || (!buffered && now >= deadline.load());
|
||||
}
|
||||
static int interrupt(void *opaque) {
|
||||
auto &in = *static_cast<Input *>(opaque);
|
||||
return in.interrupted() || (in.buffered && playback::NetworkInput::interrupt(in.buffered.get()));
|
||||
}
|
||||
static int openResource(AVFormatContext *context, AVIOContext **io, const char *url, int flags, AVDictionary **options) noexcept {
|
||||
try {
|
||||
auto &in = *static_cast<Input *>(context->opaque);
|
||||
const char *resource = std::strncmp(url, "crypto+", 7) == 0 || std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
|
||||
const auto resourceOrigin = playback::networkOrigin(resource);
|
||||
if (resourceOrigin.empty() || (in.restricted && resourceOrigin != in.origin)) return AVERROR(EACCES);
|
||||
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
// Public YouTube HLS may cross CDN origins; never carry private headers across them.
|
||||
if (result >= 0) result = av_dict_set(options, "headers", resourceOrigin == in.origin && !in.headers.empty() ? in.headers.c_str() : nullptr, 0);
|
||||
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
|
||||
if (result >= 0) result = av_dict_set(options, "rw_timeout", in.live ? "15000000" : "5000000", 0);
|
||||
if (result < 0) return result;
|
||||
return avio_open2(io, url, flags, &context->interrupt_callback, options);
|
||||
} catch (...) { return AVERROR(EACCES); }
|
||||
}
|
||||
double offset() const { return format->start_time == AV_NOPTS_VALUE ? 0 : double(format->start_time) / AV_TIME_BASE; }
|
||||
double duration() const { return format->duration == AV_NOPTS_VALUE ? 0 : double(format->duration) / AV_TIME_BASE; }
|
||||
void select(std::array<int, 2> indices, playback::StreamCacheBudget &budget) {
|
||||
for (unsigned i = 0; i < format->nb_streams; ++i)
|
||||
format->streams[i]->discard = int(i) == indices[0] || int(i) == indices[1] ?
|
||||
AVDISCARD_DEFAULT : AVDISCARD_ALL;
|
||||
if (!network || live) return;
|
||||
buffered = std::make_unique<playback::NetworkInput>(cancel, epoch, 0, wake, budget);
|
||||
buffered->format = format;
|
||||
buffered->start(indices, offset(), true);
|
||||
}
|
||||
void seek(double seconds, double audioTarget = NAN) {
|
||||
const auto timestamp = int64_t((seconds + offset()) * AV_TIME_BASE);
|
||||
if (buffered) {
|
||||
// Probing retains the opening packets. Do not reopen their HLS media
|
||||
// resource just to establish the initial zero-position cursors.
|
||||
if (!positioned && seconds == 0) { positioned = true; return; }
|
||||
positioned = true;
|
||||
// A nearby forward request can outrun the asynchronous fetch frontier.
|
||||
// Let the existing read complete rather than interrupting and refetching it.
|
||||
while (buffered->readAheadPending(seconds, audioTarget)) {
|
||||
require(!interrupted());
|
||||
std::unique_lock lock(waitLock);
|
||||
wake.wait_for(lock, std::chrono::milliseconds(20));
|
||||
}
|
||||
buffered->seek(-1, timestamp, audioTarget);
|
||||
} else { arm(); check(av_seek_frame(format, -1, timestamp, AVSEEK_FLAG_BACKWARD)); }
|
||||
}
|
||||
int read(int slot, AVPacket *destination) {
|
||||
if (!buffered) return av_read_frame(format, destination);
|
||||
for (;;) {
|
||||
require(!interrupted());
|
||||
double end = 0;
|
||||
int result = buffered->take(slot, destination, end);
|
||||
if (result != 0) return result == 1 ? 0 : result;
|
||||
std::unique_lock lock(waitLock);
|
||||
wake.wait_for(lock, std::chrono::milliseconds(20));
|
||||
}
|
||||
}
|
||||
};
|
||||
bool remuxAudio(const AVCodecParameters &codec) {
|
||||
return codec.codec_id == AV_CODEC_ID_AAC && codec.ch_layout.nb_channels <= 2 &&
|
||||
(codec.profile == AV_PROFILE_AAC_LOW || codec.profile == AV_PROFILE_AAC_HE || codec.profile == AV_PROFILE_AAC_HE_V2);
|
||||
}
|
||||
struct Track {
|
||||
Input *input;
|
||||
AVStream *stream = nullptr;
|
||||
AVCodecContext *decoder = nullptr;
|
||||
Packet pending;
|
||||
Frame decoded;
|
||||
bool eof = false, draining = false, remux = false;
|
||||
double nextTime = 0;
|
||||
Track(Input &in, int index, AVMediaType type, double start, bool live) : input(&in) {
|
||||
require(index >= 0);
|
||||
stream = input->format->streams[index];
|
||||
remux = stream->codecpar->codec_id == (type == AVMEDIA_TYPE_VIDEO ? AV_CODEC_ID_H264 : AV_CODEC_ID_AAC);
|
||||
if (type == AVMEDIA_TYPE_VIDEO) {
|
||||
const int profile = stream->codecpar->profile & ~AV_PROFILE_H264_CONSTRAINED;
|
||||
auto rate = av_guess_frame_rate(input->format, stream, nullptr);
|
||||
remux = remux && (profile == AV_PROFILE_H264_BASELINE || profile == AV_PROFILE_H264_MAIN || profile == AV_PROFILE_H264_HIGH) &&
|
||||
stream->codecpar->level > 0 && stream->codecpar->level <= 41 &&
|
||||
stream->codecpar->width <= 1920 && stream->codecpar->height <= 1080 &&
|
||||
rate.num > 0 && av_q2d(rate) <= 30.001;
|
||||
} else {
|
||||
remux = remuxAudio(*stream->codecpar);
|
||||
}
|
||||
const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ?
|
||||
std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) /
|
||||
std::max(1, stream->codecpar->sample_rate)) : start;
|
||||
if (!live && !input->buffered) input->seek(seekTarget);
|
||||
nextPacket();
|
||||
// Every VOD segment must start at its advertised time and on an IDR. If the
|
||||
// source GOP doesn't align, decode preroll and use VideoToolbox, not overlap.
|
||||
if (type == AVMEDIA_TYPE_VIDEO)
|
||||
remux = remux && !live && stream->codecpar->video_delay == 0 && pending &&
|
||||
(pending->flags & AV_PKT_FLAG_KEY) && hasIDR(*pending, *stream->codecpar) &&
|
||||
std::abs(packetTime() - start) < .001;
|
||||
if (!live && type == AVMEDIA_TYPE_VIDEO && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
|
||||
!hasIDR(*pending, *stream->codecpar) && stream->codecpar->codec_id == AV_CODEC_ID_H264) {
|
||||
// ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow.
|
||||
input->seek(0);
|
||||
nextPacket();
|
||||
}
|
||||
if (!remux) {
|
||||
const auto *codec = avcodec_find_decoder(stream->codecpar->codec_id); require(codec != nullptr);
|
||||
decoder = avcodec_alloc_context3(codec); require(decoder != nullptr);
|
||||
try {
|
||||
require(stream->codecpar->width >= 0 && stream->codecpar->height >= 0 &&
|
||||
int64_t(stream->codecpar->width) * stream->codecpar->height <= 4096LL * 2160);
|
||||
check(avcodec_parameters_to_context(decoder, stream->codecpar));
|
||||
decoder->pkt_timebase = stream->time_base;
|
||||
decoder->thread_count = 2;
|
||||
decoder->max_pixels = 4096LL * 2160;
|
||||
check(avcodec_open2(decoder, codec, nullptr));
|
||||
} catch (...) { avcodec_free_context(&decoder); throw; }
|
||||
}
|
||||
}
|
||||
~Track() { avcodec_free_context(&decoder); }
|
||||
double packetTime() const {
|
||||
if (!pending) return INFINITY;
|
||||
auto pts = pending->pts != AV_NOPTS_VALUE ? pending->pts : pending->dts;
|
||||
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
|
||||
}
|
||||
void nextPacket() {
|
||||
pending.reset();
|
||||
if (eof || !stream) return;
|
||||
auto p = packet();
|
||||
input->arm();
|
||||
for (;;) {
|
||||
require(!input->interrupted());
|
||||
int result = input->read(stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO ? 0 : 1, p.get());
|
||||
if (result == AVERROR_EOF) { eof = true; return; }
|
||||
check(result);
|
||||
require(p->size >= 0 && size_t(p->size) <= segmentLimit);
|
||||
if (p->stream_index == stream->index) { pending = std::move(p); return; }
|
||||
av_packet_unref(p.get());
|
||||
}
|
||||
}
|
||||
double frameTime() const {
|
||||
if (!decoded) return INFINITY;
|
||||
auto pts = decoded->best_effort_timestamp;
|
||||
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
|
||||
}
|
||||
void nextFrame() {
|
||||
decoded.reset(); auto f = frame();
|
||||
for (;;) {
|
||||
require(!input->interrupted());
|
||||
int result = avcodec_receive_frame(decoder, f.get());
|
||||
if (result == 0) {
|
||||
decoded = std::move(f);
|
||||
const double step = stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO ? double(decoded->nb_samples) / decoded->sample_rate : 1.0 / 30;
|
||||
nextTime = frameTime() + step;
|
||||
return;
|
||||
}
|
||||
if (result == AVERROR_EOF) return;
|
||||
if (result != AVERROR(EAGAIN)) check(result);
|
||||
if (!pending) {
|
||||
if (draining) return;
|
||||
check(avcodec_send_packet(decoder, nullptr)); draining = true;
|
||||
} else {
|
||||
check(avcodec_send_packet(decoder, pending.get())); nextPacket();
|
||||
}
|
||||
}
|
||||
}
|
||||
double time() { if (remux) return packetTime(); if (!decoded) nextFrame(); return frameTime(); }
|
||||
};
|
||||
struct Segment { int64_t id; double duration; std::vector<uint8_t> bytes; };
|
||||
struct Mux {
|
||||
AVFormatContext *format = nullptr;
|
||||
AVIOContext *io = nullptr;
|
||||
std::vector<uint8_t> bytes;
|
||||
Mux() {
|
||||
check(avformat_alloc_output_context2(&format, nullptr, "mpegts", nullptr));
|
||||
auto *buffer = static_cast<uint8_t *>(av_malloc(32768));
|
||||
if (!buffer) { avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
|
||||
io = avio_alloc_context(buffer, 32768, 1, this, nullptr, writeBytes, nullptr);
|
||||
if (!io) { av_free(buffer); avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
|
||||
format->pb = io; format->flags |= AVFMT_FLAG_CUSTOM_IO;
|
||||
}
|
||||
~Mux() { if (io) { av_freep(&io->buffer); avio_context_free(&io); } avformat_free_context(format); }
|
||||
static int writeBytes(void *opaque, const uint8_t *data, int count) {
|
||||
auto &self = *static_cast<Mux *>(opaque);
|
||||
if (count < 0 || self.bytes.size() + size_t(count) > segmentLimit) return AVERROR(ENOSPC);
|
||||
try { self.bytes.insert(self.bytes.end(), data, data + count); return count; } catch (...) { return AVERROR(ENOMEM); }
|
||||
}
|
||||
};
|
||||
struct OutputTrack {
|
||||
Track &source;
|
||||
Mux *mux;
|
||||
AVStream *stream = nullptr;
|
||||
AVCodecContext *encoder = nullptr;
|
||||
SwsContext *scale = nullptr;
|
||||
SwrContext *resample = nullptr;
|
||||
AVAudioFifo *fifo = nullptr;
|
||||
int64_t audioPTS = AV_NOPTS_VALUE;
|
||||
bool firstVideo = true;
|
||||
double lastVideoTime = -INFINITY;
|
||||
bool continuous;
|
||||
int64_t audioKeepFrom = AV_NOPTS_VALUE, audioKeepUntil = INT64_MAX;
|
||||
OutputTrack(Track &track, Mux &output, bool live) : source(track), mux(&output), continuous(live) {
|
||||
try {
|
||||
if (!source.stream) return;
|
||||
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
|
||||
if (source.remux) {
|
||||
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
|
||||
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
|
||||
return;
|
||||
}
|
||||
bool video = source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO;
|
||||
const auto *codec = video ? avcodec_find_encoder_by_name("h264_videotoolbox") : avcodec_find_encoder(AV_CODEC_ID_AAC);
|
||||
require(codec != nullptr); encoder = avcodec_alloc_context3(codec); require(encoder != nullptr);
|
||||
if (video) {
|
||||
require(source.decoder->width > 0 && source.decoder->height > 0 &&
|
||||
int64_t(source.decoder->width) * source.decoder->height <= 4096LL * 2160);
|
||||
auto sar = av_guess_sample_aspect_ratio(source.input->format, source.stream, nullptr);
|
||||
if (sar.num <= 0 || sar.den <= 0) sar = {1, 1};
|
||||
const double displayWidth = source.decoder->width * av_q2d(sar);
|
||||
const double ratio = std::min({1.0, 1920.0 / displayWidth, 1080.0 / source.decoder->height});
|
||||
encoder->width = int(displayWidth * ratio) & ~1;
|
||||
encoder->height = int(source.decoder->height * ratio) & ~1;
|
||||
require(encoder->width > 0 && encoder->height > 0);
|
||||
// VideoToolbox can omit SAR in its H264 bitstream; encode square pixels instead.
|
||||
encoder->sample_aspect_ratio = {1, 1};
|
||||
encoder->pix_fmt = AV_PIX_FMT_NV12; encoder->time_base = {1, 90000};
|
||||
encoder->framerate = av_guess_frame_rate(source.input->format, source.stream, nullptr);
|
||||
if (encoder->framerate.num <= 0 || av_q2d(encoder->framerate) > 30) encoder->framerate = {30, 1};
|
||||
encoder->bit_rate = 6000000; encoder->gop_size = 180; encoder->max_b_frames = 0;
|
||||
check(av_opt_set(encoder->priv_data, "profile", "main", 0));
|
||||
check(av_opt_set(encoder->priv_data, "level", "4.1", 0));
|
||||
// VideoToolbox may use its platform software fallback, never a host executable.
|
||||
av_opt_set(encoder->priv_data, "allow_sw", "1", 0);
|
||||
av_opt_set(encoder->priv_data, "realtime", "1", 0);
|
||||
} else {
|
||||
encoder->sample_rate = 48000; encoder->sample_fmt = AV_SAMPLE_FMT_FLTP;
|
||||
av_channel_layout_default(&encoder->ch_layout, 2); encoder->time_base = {1, 48000}; encoder->bit_rate = 192000;
|
||||
fifo = av_audio_fifo_alloc(encoder->sample_fmt, 2, 4096); require(fifo != nullptr);
|
||||
}
|
||||
check(avcodec_open2(encoder, codec, nullptr));
|
||||
stream->time_base = encoder->time_base; check(avcodec_parameters_from_context(stream->codecpar, encoder));
|
||||
} catch (...) { avcodec_free_context(&encoder); av_audio_fifo_free(fifo); fifo = nullptr; throw; }
|
||||
}
|
||||
~OutputTrack() { avcodec_free_context(&encoder); sws_freeContext(scale); swr_free(&resample); av_audio_fifo_free(fifo); }
|
||||
void beginSegment(Mux &output) {
|
||||
mux = &output;
|
||||
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
|
||||
if (source.remux) {
|
||||
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
|
||||
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
|
||||
} else {
|
||||
stream->time_base = encoder->time_base;
|
||||
check(avcodec_parameters_from_context(stream->codecpar, encoder));
|
||||
}
|
||||
firstVideo = true;
|
||||
}
|
||||
void writePacket(AVPacket *packet) {
|
||||
// A shared, positive TS clock origin prevents per-segment AAC priming shifts.
|
||||
const auto offset = av_rescale_q(1, AVRational{1, 1}, stream->time_base);
|
||||
if (packet->pts != AV_NOPTS_VALUE) packet->pts += offset;
|
||||
if (packet->dts != AV_NOPTS_VALUE) packet->dts += offset;
|
||||
packet->stream_index = stream->index;
|
||||
check(av_interleaved_write_frame(mux->format, packet));
|
||||
}
|
||||
void encoded(AVFrame *f) {
|
||||
check(avcodec_send_frame(encoder, f)); auto p = packet();
|
||||
for (;;) {
|
||||
int result = avcodec_receive_packet(encoder, p.get());
|
||||
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break;
|
||||
check(result);
|
||||
if (fifo && !continuous && audioKeepFrom != AV_NOPTS_VALUE &&
|
||||
(p->pts < audioKeepFrom || p->pts >= audioKeepUntil)) {
|
||||
av_packet_unref(p.get());
|
||||
continue;
|
||||
}
|
||||
av_packet_rescale_ts(p.get(), encoder->time_base, stream->time_base);
|
||||
writePacket(p.get()); av_packet_unref(p.get());
|
||||
}
|
||||
}
|
||||
void audioFrames(bool finish, double end = INFINITY) {
|
||||
const int count = encoder->frame_size;
|
||||
while ((av_audio_fifo_size(fifo) >= count || (finish && av_audio_fifo_size(fifo) > 0)) &&
|
||||
(audioPTS == AV_NOPTS_VALUE || double(audioPTS) / 48000 < end)) {
|
||||
auto f = frame(); f->format = encoder->sample_fmt; f->sample_rate = encoder->sample_rate;
|
||||
check(av_channel_layout_copy(&f->ch_layout, &encoder->ch_layout)); f->nb_samples = count;
|
||||
check(av_frame_get_buffer(f.get(), 0));
|
||||
int available = std::min(count, av_audio_fifo_size(fifo));
|
||||
require(av_audio_fifo_read(fifo, reinterpret_cast<void **>(f->data), available) == available);
|
||||
if (available < count) av_samples_set_silence(f->data, available, count - available, 2, encoder->sample_fmt);
|
||||
f->pts = audioPTS; audioPTS += count; encoded(f.get());
|
||||
}
|
||||
}
|
||||
void consume(double start, double end) {
|
||||
if (source.remux) {
|
||||
double time = source.packetTime();
|
||||
if (time >= start - .001 && time < end) {
|
||||
auto *p = source.pending.get();
|
||||
int64_t offset = int64_t(source.input->offset() / av_q2d(source.stream->time_base));
|
||||
if (p->pts != AV_NOPTS_VALUE) p->pts -= offset;
|
||||
if (p->dts != AV_NOPTS_VALUE) p->dts -= offset;
|
||||
av_packet_rescale_ts(p, source.stream->time_base, stream->time_base); p->stream_index = stream->index;
|
||||
writePacket(p);
|
||||
}
|
||||
source.nextPacket(); return;
|
||||
}
|
||||
auto *f = source.decoded.get(); double time = source.frameTime();
|
||||
if (source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
|
||||
if (time >= start - .001 && time < end && time - lastVideoTime >= 1.0 / 30 - .00001) {
|
||||
require(f->width > 0 && f->height > 0 && int64_t(f->width) * f->height <= 4096LL * 2160);
|
||||
auto out = frame(); out->format = encoder->pix_fmt; out->width = encoder->width; out->height = encoder->height;
|
||||
out->sample_aspect_ratio = encoder->sample_aspect_ratio;
|
||||
check(av_frame_get_buffer(out.get(), 32));
|
||||
scale = sws_getCachedContext(scale, f->width, f->height, AVPixelFormat(f->format), out->width, out->height, encoder->pix_fmt, SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||
require(scale != nullptr); check(sws_scale(scale, f->data, f->linesize, 0, f->height, out->data, out->linesize));
|
||||
out->pts = int64_t(std::llround(std::max(start, time) * 90000));
|
||||
out->pict_type = firstVideo ? AV_PICTURE_TYPE_I : AV_PICTURE_TYPE_NONE; firstVideo = false; encoded(out.get());
|
||||
lastVideoTime = time;
|
||||
}
|
||||
} else {
|
||||
require(f->sample_rate > 0 && f->nb_samples >= 0 && f->nb_samples <= 65536 && f->ch_layout.nb_channels <= 32);
|
||||
const double begin = continuous ? start : aacPreroll(start);
|
||||
const double limit = continuous ? end : double(aacBoundary(end)) / 48000;
|
||||
if (!continuous) {
|
||||
audioKeepFrom = aacBoundary(start);
|
||||
audioKeepUntil = int64_t(std::llround(end * 48000));
|
||||
}
|
||||
if (time + double(f->nb_samples) / f->sample_rate > begin && time < limit) {
|
||||
if (!resample) {
|
||||
check(swr_alloc_set_opts2(&resample, &encoder->ch_layout, encoder->sample_fmt, 48000, &f->ch_layout, AVSampleFormat(f->format), f->sample_rate, 0, nullptr));
|
||||
check(swr_init(resample));
|
||||
}
|
||||
int capacity = int(av_rescale_rnd(swr_get_delay(resample, f->sample_rate) + f->nb_samples, 48000, f->sample_rate, AV_ROUND_UP));
|
||||
require(capacity > 0 && capacity <= 131072);
|
||||
auto out = frame(); out->format = encoder->sample_fmt; out->sample_rate = 48000;
|
||||
check(av_channel_layout_copy(&out->ch_layout, &encoder->ch_layout)); out->nb_samples = capacity; check(av_frame_get_buffer(out.get(), 0));
|
||||
int count = swr_convert(resample, out->data, capacity, const_cast<const uint8_t **>(f->extended_data), f->nb_samples); check(count);
|
||||
int skip = continuous && audioPTS != AV_NOPTS_VALUE ? 0 :
|
||||
std::clamp(int(std::llround((begin - time) * 48000)), 0, count);
|
||||
int take = continuous ? count - skip :
|
||||
std::clamp(int(std::llround((limit - time) * 48000)) - skip, 0, count - skip);
|
||||
if (audioPTS == AV_NOPTS_VALUE) audioPTS = int64_t(std::llround(std::max(begin, time) * 48000));
|
||||
uint8_t *data[2] = {out->data[0] + skip * 4, out->data[1] + skip * 4};
|
||||
require(av_audio_fifo_size(fifo) + take <= 131072);
|
||||
require(av_audio_fifo_write(fifo, reinterpret_cast<void **>(data), take) == take); audioFrames(false, end);
|
||||
}
|
||||
}
|
||||
source.nextFrame();
|
||||
}
|
||||
void finish(double end) {
|
||||
if (!encoder) return;
|
||||
if (continuous) {
|
||||
if (fifo) audioFrames(false, end);
|
||||
} else {
|
||||
if (fifo) audioFrames(true);
|
||||
encoded(nullptr);
|
||||
}
|
||||
}
|
||||
};
|
||||
struct Pipeline {
|
||||
playback::StreamCacheBudget cacheBudget;
|
||||
std::unique_ptr<Input> videoInput, audioInput;
|
||||
Input *audioSource = nullptr;
|
||||
int videoIndex = -1, audioIndex = -1;
|
||||
Clock::time_point deadline;
|
||||
std::unique_ptr<Track> video, audio;
|
||||
std::unique_ptr<OutputTrack> videoOutput, audioOutput;
|
||||
bool live;
|
||||
std::atomic<bool> &cancel;
|
||||
double duration = 0;
|
||||
Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) {
|
||||
videoInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
|
||||
duration = std::max(source.duration, videoInput->duration());
|
||||
videoIndex = av_find_best_stream(videoInput->format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
|
||||
if (!source.audio.empty()) {
|
||||
require(videoIndex >= 0);
|
||||
audioInput = std::make_unique<Input>(stop, source.audio, source.audioHeaders, false, source.restricted, source.live);
|
||||
audioSource = audioInput.get();
|
||||
audioIndex = av_find_best_stream(audioSource->format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
|
||||
} else {
|
||||
audioIndex = av_find_best_stream(videoInput->format, AVMEDIA_TYPE_AUDIO, -1, videoIndex, nullptr, 0);
|
||||
if (audioIndex >= 0) {
|
||||
if (videoIndex >= 0 && (source.local || source.live)) {
|
||||
audioInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
|
||||
audioIndex = av_find_best_stream(audioInput->format, AVMEDIA_TYPE_AUDIO, -1, videoIndex, nullptr, 0);
|
||||
audioSource = audioInput.get();
|
||||
} else audioSource = videoInput.get();
|
||||
}
|
||||
}
|
||||
require(videoIndex >= 0 || audioIndex >= 0);
|
||||
videoInput->select({audioSource == videoInput.get() ? audioIndex : -1, videoIndex}, cacheBudget);
|
||||
if (audioInput) audioInput->select({audioIndex, -1}, cacheBudget);
|
||||
reset(start);
|
||||
if (!source.audio.empty()) require(audio && audio->stream);
|
||||
require(video || audio);
|
||||
if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14);
|
||||
releaseOperation();
|
||||
}
|
||||
void armOperation() {
|
||||
deadline = Clock::now() + std::chrono::seconds(45);
|
||||
const auto limit = std::chrono::duration_cast<std::chrono::nanoseconds>(deadline.time_since_epoch()).count();
|
||||
videoInput->operationDeadline = limit;
|
||||
if (audioInput) audioInput->operationDeadline = limit;
|
||||
}
|
||||
void releaseOperation() {
|
||||
videoInput->operationDeadline = std::numeric_limits<int64_t>::max();
|
||||
if (audioInput) audioInput->operationDeadline = std::numeric_limits<int64_t>::max();
|
||||
}
|
||||
void reset(double start) {
|
||||
armOperation();
|
||||
videoOutput.reset(); audioOutput.reset();
|
||||
video.reset(); audio.reset();
|
||||
// Fresh codecs retain independent IDRs/AAC priming. One recorded network
|
||||
// demuxer atomically rewinds both packet lanes, seeking upstream only on misses.
|
||||
const double audioTarget = audioIndex >= 0 && !remuxAudio(*audioSource->format->streams[audioIndex]->codecpar) ?
|
||||
aacPreroll(start) : start;
|
||||
if (videoInput->buffered)
|
||||
videoInput->seek(start, audioSource == videoInput.get() ? audioTarget : NAN);
|
||||
if (audioInput && audioInput->buffered) audioInput->seek(start, audioTarget);
|
||||
if (videoIndex >= 0) video = std::make_unique<Track>(*videoInput, videoIndex, AVMEDIA_TYPE_VIDEO, start, live);
|
||||
if (audioIndex >= 0) audio = std::make_unique<Track>(*audioSource, audioIndex, AVMEDIA_TYPE_AUDIO, start, live);
|
||||
}
|
||||
Segment segment(int64_t id, double start, double end) {
|
||||
if (live) armOperation();
|
||||
Mux mux;
|
||||
if (video) {
|
||||
videoOutput.reset();
|
||||
videoOutput = std::make_unique<OutputTrack>(*video, mux, false);
|
||||
}
|
||||
if (audio) {
|
||||
if (audioOutput) audioOutput->beginSegment(mux);
|
||||
else audioOutput = std::make_unique<OutputTrack>(*audio, mux, live);
|
||||
}
|
||||
AVDictionary *options = nullptr;
|
||||
av_dict_set(&options, "mpegts_flags", "resend_headers", 0);
|
||||
av_dict_set(&options, "mpegts_copyts", "1", 0);
|
||||
av_dict_set(&options, "muxdelay", "0", 0);
|
||||
int result = avformat_write_header(mux.format, &options); av_dict_free(&options); check(result);
|
||||
const double audioEnd = audio && !audio->remux && !live ? double(aacBoundary(end)) / 48000 : end;
|
||||
for (;;) {
|
||||
require(!cancel && Clock::now() < deadline);
|
||||
double vt = video ? video->time() : INFINITY, at = audio ? audio->time() : INFINITY;
|
||||
if (vt >= end) vt = INFINITY;
|
||||
if (at >= audioEnd) at = INFINITY;
|
||||
if (!std::isfinite(vt) && !std::isfinite(at)) break;
|
||||
if (vt <= at) videoOutput->consume(start, end); else audioOutput->consume(start, end);
|
||||
}
|
||||
if (videoOutput) videoOutput->finish(end);
|
||||
if (audioOutput) audioOutput->finish(end);
|
||||
check(av_write_trailer(mux.format)); avio_flush(mux.io);
|
||||
require(mux.io->error >= 0 && !mux.bytes.empty());
|
||||
if (videoInput->buffered) videoInput->buffered->updatePosition(end);
|
||||
if (audioInput && audioInput->buffered) audioInput->buffered->updatePosition(end);
|
||||
releaseOperation();
|
||||
return {id, end - start, std::move(mux.bytes)};
|
||||
}
|
||||
};
|
||||
bool number(std::string_view text, int64_t &value) {
|
||||
if (text.empty()) return false;
|
||||
auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
return result.ec == std::errc() && result.ptr == text.data() + text.size() && value >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
struct CastMedia {
|
||||
Source source;
|
||||
std::atomic<bool> cancelled{false};
|
||||
std::mutex lock;
|
||||
std::mutex vodLock;
|
||||
std::unique_ptr<Pipeline> vodPipeline;
|
||||
std::condition_variable wake;
|
||||
int listener = -1;
|
||||
std::vector<int> clients;
|
||||
std::array<std::thread, 2> servers;
|
||||
std::thread producer;
|
||||
std::string base, token;
|
||||
double duration = 0;
|
||||
std::deque<std::shared_ptr<const Segment>> segments;
|
||||
std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles;
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
double testSeconds = 6;
|
||||
#endif
|
||||
bool liveFailed = false;
|
||||
explicit CastMedia(Source input) : source(std::move(input)) {}
|
||||
~CastMedia() {
|
||||
cancel();
|
||||
if (producer.joinable()) producer.join();
|
||||
for (auto &worker : servers) if (worker.joinable()) worker.join();
|
||||
if (listener >= 0) close(listener);
|
||||
}
|
||||
void cancel() {
|
||||
cancelled = true; wake.notify_all();
|
||||
std::lock_guard guard(lock);
|
||||
if (listener >= 0) shutdown(listener, SHUT_RDWR);
|
||||
for (int socket : clients) shutdown(socket, SHUT_RDWR);
|
||||
}
|
||||
void bindReceiver(const char *host) {
|
||||
require(host && std::strlen(host) <= 1024);
|
||||
addrinfo hints{}; hints.ai_socktype = SOCK_DGRAM; hints.ai_family = AF_INET;
|
||||
addrinfo *addresses = nullptr;
|
||||
if (getaddrinfo(host, "8009", &hints, &addresses) != 0) {
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
require(getaddrinfo(host, "8009", &hints, &addresses) == 0);
|
||||
}
|
||||
std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> owner(addresses, freeaddrinfo);
|
||||
sockaddr_storage local{}; socklen_t length = 0; int family = 0;
|
||||
for (auto *address = addresses; address; address = address->ai_next) {
|
||||
int route = socket(address->ai_family, SOCK_DGRAM, 0);
|
||||
if (route < 0) continue;
|
||||
length = sizeof(local);
|
||||
bool found = connect(route, address->ai_addr, address->ai_addrlen) == 0 && getsockname(route, reinterpret_cast<sockaddr *>(&local), &length) == 0;
|
||||
close(route);
|
||||
if (found) { family = address->ai_family; break; }
|
||||
}
|
||||
require(family != 0 && !cancelled);
|
||||
char name[NI_MAXHOST]; require(getnameinfo(reinterpret_cast<sockaddr *>(&local), length, name, sizeof(name), nullptr, 0, NI_NUMERICHOST) == 0);
|
||||
if (family == AF_INET) reinterpret_cast<sockaddr_in *>(&local)->sin_port = 0;
|
||||
else reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port = 0;
|
||||
int server = socket(family, SOCK_STREAM, 0); require(server >= 0);
|
||||
if (fcntl(server, F_SETFL, O_NONBLOCK) < 0) { close(server); require(false); }
|
||||
int noSIGPIPE = 1; setsockopt(server, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, sizeof(noSIGPIPE));
|
||||
if (::bind(server, reinterpret_cast<sockaddr *>(&local), length) != 0 || listen(server, 8) != 0 || getsockname(server, reinterpret_cast<sockaddr *>(&local), &length) != 0) { close(server); require(false); }
|
||||
unsigned port = ntohs(family == AF_INET ? reinterpret_cast<sockaddr_in *>(&local)->sin_port : reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port);
|
||||
std::array<unsigned char, 32> random{}; arc4random_buf(random.data(), random.size());
|
||||
for (auto byte : random) { char hex[3]; std::snprintf(hex, sizeof(hex), "%02x", byte); token += hex; }
|
||||
std::string hostname = name;
|
||||
// URLComponents expects escaped IPv6 scope identifiers.
|
||||
auto scope = hostname.find('%'); if (scope != std::string::npos) hostname.replace(scope, 1, "%25");
|
||||
base = "http://" + (family == AF_INET6 ? "[" + hostname + "]" : hostname) + ":" + std::to_string(port) + "/" + token + "/";
|
||||
std::lock_guard guard(lock); listener = server;
|
||||
if (cancelled) shutdown(listener, SHUT_RDWR);
|
||||
}
|
||||
void produce(std::unique_ptr<Pipeline> pipeline) {
|
||||
try {
|
||||
double first = std::min(pipeline->video ? pipeline->video->time() : INFINITY, pipeline->audio ? pipeline->audio->time() : INFINITY);
|
||||
require(std::isfinite(first));
|
||||
#ifndef CAST_MEDIA_CHECK
|
||||
auto origin = Clock::now();
|
||||
#endif
|
||||
for (int64_t id = 0; !cancelled; ++id) {
|
||||
auto segment = pipeline->segment(id, first + id * segmentSeconds, first + (id + 1) * segmentSeconds);
|
||||
{ std::lock_guard guard(lock); segments.push_back(std::make_shared<const Segment>(std::move(segment))); while (segments.size() > windowSegments) segments.pop_front(); }
|
||||
wake.notify_all();
|
||||
std::unique_lock guard(lock);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
wake.wait(guard, [&] { return cancelled || testSeconds >= (id + 1) * segmentSeconds; });
|
||||
#else
|
||||
wake.wait_until(guard, origin + std::chrono::milliseconds(int64_t((id + 1) * segmentSeconds * 1000)), [&] { return bool(cancelled); });
|
||||
#endif
|
||||
}
|
||||
} catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); }
|
||||
}
|
||||
std::vector<uint8_t> playlist() {
|
||||
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n";
|
||||
int64_t first = 0, count = int64_t(std::ceil(duration / segmentSeconds));
|
||||
std::lock_guard guard(lock);
|
||||
if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); }
|
||||
else text += "#EXT-X-PLAYLIST-TYPE:VOD\n";
|
||||
text += "#EXT-X-MEDIA-SEQUENCE:" + std::to_string(first) + "\n";
|
||||
if (source.live) text += "#EXT-X-DISCONTINUITY-SEQUENCE:" + std::to_string(first) + "\n";
|
||||
for (int64_t id = first; id < first + count; ++id) {
|
||||
char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : std::min(segmentSeconds, duration - id * segmentSeconds);
|
||||
std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length);
|
||||
// Each independently generated segment resets codec/mux state.
|
||||
if (id > first) text += "#EXT-X-DISCONTINUITY\n";
|
||||
text += value; text += "segment/" + std::to_string(id) + ".ts\n";
|
||||
}
|
||||
if (!source.live) text += "#EXT-X-ENDLIST\n";
|
||||
return {text.begin(), text.end()};
|
||||
}
|
||||
std::shared_ptr<const Segment> vodSegment(int64_t id) {
|
||||
auto cached = [&]() -> std::shared_ptr<const Segment> {
|
||||
std::lock_guard guard(lock);
|
||||
auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
|
||||
return found == segments.end() ? nullptr : *found;
|
||||
};
|
||||
if (auto segment = cached()) return segment;
|
||||
// ponytail: uncached VOD generation is serialized; use a bounded input
|
||||
// pool only if parallel uncached requests justify duplicate probe state.
|
||||
std::lock_guard generation(vodLock);
|
||||
require(!cancelled);
|
||||
if (auto segment = cached()) return segment;
|
||||
try {
|
||||
if (vodPipeline) vodPipeline->reset(id * segmentSeconds);
|
||||
else vodPipeline = std::make_unique<Pipeline>(source, cancelled, id * segmentSeconds);
|
||||
auto segment = std::make_shared<const Segment>(vodPipeline->segment(
|
||||
id, id * segmentSeconds, std::min(duration, (id + 1) * segmentSeconds)));
|
||||
{ std::lock_guard guard(lock); segments.push_back(segment); while (segments.size() > windowSegments) segments.pop_front(); }
|
||||
return segment;
|
||||
} catch (...) {
|
||||
// A failed demux/decode is never cached or reused. A later receiver
|
||||
// request can prepare fresh inputs; this request still receives 503.
|
||||
vodPipeline.reset();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void sendAll(int socket, const uint8_t *bytes, size_t size) {
|
||||
const auto deadline = Clock::now() + std::chrono::seconds(15);
|
||||
while (size) {
|
||||
require(!cancelled && Clock::now() < deadline);
|
||||
pollfd descriptor{socket, POLLOUT, 0}; require(poll(&descriptor, 1, 1000) >= 0);
|
||||
if (!(descriptor.revents & POLLOUT)) continue;
|
||||
ssize_t count = send(socket, bytes, std::min(size, size_t(65536)), 0); require(count > 0);
|
||||
bytes += count; size -= size_t(count);
|
||||
}
|
||||
}
|
||||
void response(int socket, int status, const char *type, const std::vector<uint8_t> &bytes, bool head, std::string_view range = {}) {
|
||||
size_t begin = 0, end = bytes.size(); std::string extra;
|
||||
if (!range.empty() && status == 200) {
|
||||
int64_t low = 0, high = 0; auto split = range.find('-');
|
||||
if (!range.starts_with("bytes=") || split == std::string_view::npos || range.find(',') != std::string_view::npos) status = 416;
|
||||
else if (split == 6) {
|
||||
if (!number(range.substr(7), high) || high == 0 || bytes.empty()) status = 416;
|
||||
else begin = bytes.size() - std::min(bytes.size(), size_t(high));
|
||||
} else if (!number(range.substr(6, split - 6), low) || size_t(low) >= bytes.size()) status = 416;
|
||||
else {
|
||||
begin = size_t(low);
|
||||
if (split + 1 < range.size()) {
|
||||
if (!number(range.substr(split + 1), high) || high < low) status = 416;
|
||||
else end = std::min(bytes.size(), size_t(high) + 1);
|
||||
}
|
||||
}
|
||||
if (status != 416) { status = 206; extra = "Content-Range: bytes " + std::to_string(begin) + "-" + std::to_string(end - 1) + "/" + std::to_string(bytes.size()) + "\r\n"; }
|
||||
else { extra = "Content-Range: bytes */" + std::to_string(bytes.size()) + "\r\n"; begin = end = 0; }
|
||||
}
|
||||
std::string header = "HTTP/1.1 " + std::to_string(status) + (status == 200 ? " OK" : status == 206 ? " Partial Content" : " Error") + "\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, HEAD, OPTIONS\r\nAccess-Control-Allow-Headers: Range\r\nAccess-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\nCache-Control: no-store\r\nAccept-Ranges: bytes\r\nContent-Type: " + type + "\r\n" + extra + "Content-Length: " + std::to_string(end - begin) + "\r\n\r\n";
|
||||
sendAll(socket, reinterpret_cast<const uint8_t *>(header.data()), header.size());
|
||||
if (!head && end > begin) sendAll(socket, bytes.data() + begin, end - begin);
|
||||
}
|
||||
void serve(int socket) {
|
||||
std::string request; std::array<char, 2048> buffer{};
|
||||
auto deadline = Clock::now() + std::chrono::seconds(5);
|
||||
while (request.find("\r\n\r\n") == std::string::npos) {
|
||||
require(!cancelled && request.size() < 16384 && Clock::now() < deadline);
|
||||
pollfd descriptor{socket, POLLIN, 0}; require(poll(&descriptor, 1, 500) >= 0);
|
||||
if (!(descriptor.revents & POLLIN)) continue;
|
||||
ssize_t size = recv(socket, buffer.data(), std::min(buffer.size(), 16384 - request.size()), 0); require(size > 0); request.append(buffer.data(), size_t(size));
|
||||
}
|
||||
auto first = request.find(' '), second = request.find(' ', first == std::string::npos ? 0 : first + 1);
|
||||
require(first != std::string::npos && second != std::string::npos && second < request.find("\r\n"));
|
||||
auto method = request.substr(0, first), path = request.substr(first + 1, second - first - 1);
|
||||
bool head = method == "HEAD";
|
||||
if (!path.starts_with("/" + token + "/")) { response(socket, 404, "text/plain", {}, head); return; }
|
||||
if (method == "OPTIONS") { response(socket, 204, "text/plain", {}, true); return; }
|
||||
if (method != "GET" && !head) { response(socket, 405, "text/plain", {}, true); return; }
|
||||
std::string range;
|
||||
size_t cursor = request.find("\r\n") + 2;
|
||||
while (cursor < request.size()) {
|
||||
auto next = request.find("\r\n", cursor); if (next == cursor || next == std::string::npos) break;
|
||||
auto line = request.substr(cursor, next - cursor); auto colon = line.find(':'); require(colon != std::string::npos);
|
||||
auto name = line.substr(0, colon); for (char &c : name) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
|
||||
if (name == "range") { require(range.empty()); auto begin = line.find_first_not_of(' ', colon + 1); require(begin != std::string::npos); range = line.substr(begin); }
|
||||
cursor = next + 2;
|
||||
}
|
||||
path.erase(0, token.size() + 2);
|
||||
std::vector<uint8_t> data; const char *type = "video/mp2t"; int status = 200;
|
||||
std::shared_ptr<const std::vector<uint8_t>> retained;
|
||||
if (path == "index.m3u8") { data = playlist(); type = "application/vnd.apple.mpegurl"; }
|
||||
else if (path.starts_with("subtitle/") && path.ends_with(".vtt")) {
|
||||
int64_t id = 0;
|
||||
if (!number(std::string_view(path).substr(9, path.size() - 13), id) || id > 32) status = 404;
|
||||
else { std::lock_guard guard(lock); auto found = subtitles.find(int(id)); if (found == subtitles.end()) status = 404; else retained = found->second; }
|
||||
type = "text/vtt; charset=utf-8";
|
||||
} else if (path.starts_with("segment/") && path.ends_with(".ts")) {
|
||||
int64_t id = 0;
|
||||
if (!number(std::string_view(path).substr(8, path.size() - 11), id)) status = 404;
|
||||
else if (source.live) {
|
||||
std::lock_guard guard(lock); auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
|
||||
if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes);
|
||||
} else if (double(id) * segmentSeconds >= duration) status = 404;
|
||||
else {
|
||||
auto segment = vodSegment(id);
|
||||
retained = std::shared_ptr<const std::vector<uint8_t>>(segment, &segment->bytes);
|
||||
}
|
||||
} else status = 404;
|
||||
response(socket, status, type, retained ? *retained : data, head, range);
|
||||
}
|
||||
void server() {
|
||||
while (!cancelled) {
|
||||
pollfd descriptor{listener, POLLIN, 0}; if (poll(&descriptor, 1, 500) <= 0) continue;
|
||||
if (!(descriptor.revents & POLLIN)) continue;
|
||||
int socket = accept(listener, nullptr, nullptr); if (socket < 0) continue;
|
||||
int one = 1; setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
|
||||
timeval timeout{1, 0}; setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
{ std::lock_guard guard(lock); clients.push_back(socket); if (cancelled) shutdown(socket, SHUT_RDWR); }
|
||||
try { serve(socket); } catch (...) { try { response(socket, 503, "text/plain", {}, true); } catch (...) {} }
|
||||
{ std::lock_guard guard(lock); clients.erase(std::remove(clients.begin(), clients.end(), socket), clients.end()); close(socket); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern "C" CastMedia *cast_media_create(const CastMediaSource *input, char error[512]) {
|
||||
try {
|
||||
require(input && input->video_url && std::strlen(input->video_url) <= 65536);
|
||||
require(playback::validHeaders(input->video_headers) && playback::validHeaders(input->audio_headers));
|
||||
require(std::isfinite(input->duration) && input->duration >= 0);
|
||||
std::string video = input->video_url;
|
||||
if (input->local) require(!video.empty() && video.front() == '/' && video.find('\0') == std::string::npos);
|
||||
else require(playback::validNetworkURL(video.c_str()));
|
||||
if (input->audio_url && *input->audio_url) require(playback::validNetworkURL(input->audio_url));
|
||||
av_log_set_callback(privateLog);
|
||||
// Bound demuxer-controlled allocations before probing or packet reads allocate them.
|
||||
av_max_alloc(128 * 1024 * 1024);
|
||||
return new CastMedia({video, input->video_headers ? input->video_headers : "", input->audio_url ? input->audio_url : "", input->audio_headers ? input->audio_headers : "", input->local, input->restricted, input->live, input->duration});
|
||||
} catch (...) { if (error) std::snprintf(error, 512, "Could not prepare cast media (private details redacted)."); return nullptr; }
|
||||
}
|
||||
extern "C" bool cast_media_start(CastMedia *media, const char *host, char url[512], double *duration, char error[512]) {
|
||||
try {
|
||||
require(media && host && url && duration && !media->cancelled);
|
||||
auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0);
|
||||
media->duration = pipeline->duration;
|
||||
media->bindReceiver(host);
|
||||
if (media->source.live) {
|
||||
media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); });
|
||||
std::unique_lock guard(media->lock);
|
||||
require(media->wake.wait_for(guard, std::chrono::seconds(50), [&] { return media->segments.size() >= 2 || media->liveFailed || media->cancelled; }));
|
||||
require(media->segments.size() >= 2 && !media->liveFailed && !media->cancelled);
|
||||
} else media->vodPipeline = std::move(pipeline);
|
||||
for (auto &worker : media->servers) worker = std::thread([media] { media->server(); });
|
||||
require(media->base.size() + 11 < 512); std::snprintf(url, 512, "%sindex.m3u8", media->base.c_str()); *duration = media->source.live ? 0 : media->duration;
|
||||
return true;
|
||||
} catch (...) { if (media) media->cancel(); if (error) std::snprintf(error, 512, "Could not start cast delivery (private details redacted)."); return false; }
|
||||
}
|
||||
extern "C" bool cast_media_subtitle(CastMedia *media, int id, const void *bytes, size_t size, char url[512]) {
|
||||
try {
|
||||
require(media && !media->cancelled && id > 0 && id <= 32 && bytes && size > 0 && size <= 4 * 1024 * 1024 && url);
|
||||
std::lock_guard guard(media->lock);
|
||||
auto *data = static_cast<const uint8_t *>(bytes); media->subtitles[id] = std::make_shared<const std::vector<uint8_t>>(data, data + size);
|
||||
require(media->base.size() + 32 < 512); std::snprintf(url, 512, "%ssubtitle/%d.vtt", media->base.c_str(), id); return true;
|
||||
} catch (...) { return false; }
|
||||
}
|
||||
extern "C" void cast_media_cancel(CastMedia *media) { if (media) media->cancel(); }
|
||||
extern "C" void cast_media_destroy(CastMedia *media) { delete media; }
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
extern "C" bool cast_media_check_advance(CastMedia *media, double seconds) {
|
||||
if (!media || !media->source.live || !std::isfinite(seconds) || seconds < 6 || seconds > 3600) return false;
|
||||
std::unique_lock guard(media->lock);
|
||||
media->testSeconds = seconds;
|
||||
media->wake.notify_all();
|
||||
const auto target = int64_t(seconds / segmentSeconds);
|
||||
media->wake.wait(guard, [&] { return media->cancelled || media->liveFailed ||
|
||||
(!media->segments.empty() && media->segments.back()->id >= target); });
|
||||
return !media->cancelled && !media->liveFailed;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct CastMedia CastMedia;
|
||||
typedef struct CastMediaSource {
|
||||
const char *video_url;
|
||||
const char *video_headers;
|
||||
const char *audio_url;
|
||||
const char *audio_headers;
|
||||
bool local;
|
||||
bool restricted;
|
||||
bool live;
|
||||
double duration;
|
||||
} CastMediaSource;
|
||||
|
||||
// create copies input. start/probe/destroy may block; call off the UI thread.
|
||||
CastMedia *cast_media_create(const CastMediaSource *source, char error[512]);
|
||||
bool cast_media_start(CastMedia *media, const char *receiver_host, char url[512], double *duration, char error[512]);
|
||||
// Copies a validated UTF-8 WebVTT resource, at most 4 MiB/track and 32 tracks.
|
||||
bool cast_media_subtitle(CastMedia *media, int identifier, const void *bytes, size_t size, char url[512]);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
// Dedicated check build only: fixed monotonic clock; waits for publication.
|
||||
bool cast_media_check_advance(CastMedia *media, double seconds);
|
||||
#endif
|
||||
// Nonblocking cancellation; destroy subsequently joins all workers and closes sockets.
|
||||
void cast_media_cancel(CastMedia *media);
|
||||
void cast_media_destroy(CastMedia *media);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -17,6 +17,7 @@ extern "C" {
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace playback {
|
||||
@@ -49,6 +50,9 @@ class NetworkInput {
|
||||
public:
|
||||
static constexpr double aheadSeconds = 60, behindSeconds = 30;
|
||||
AVFormatContext *format = nullptr;
|
||||
// Resolved sources bind credentials to their original origin for all HLS I/O.
|
||||
std::string headerOrigin;
|
||||
std::string requestHeaders;
|
||||
|
||||
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
|
||||
uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget)
|
||||
@@ -121,7 +125,9 @@ public:
|
||||
for (int slot = 0; slot < 2; ++slot) trim(slot);
|
||||
budget.changed.notify_all();
|
||||
}
|
||||
void seek(int streamIndex, int64_t timestamp) {
|
||||
// Optional audio target gives fresh segment encoders their AAC preroll without
|
||||
// moving the video cursor away from its own keyframe. Omission preserves player seeks.
|
||||
void seek(int streamIndex, int64_t timestamp, double audioTarget = NAN) {
|
||||
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;
|
||||
@@ -134,7 +140,7 @@ public:
|
||||
// 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]);
|
||||
else cached = cachedCursor(slot, slot == 0 && std::isfinite(audioTarget) ? audioTarget : target, cursors[slot]);
|
||||
}
|
||||
position = std::max(0.0, target);
|
||||
if (cached) {
|
||||
@@ -152,6 +158,11 @@ public:
|
||||
failure = 0;
|
||||
seekIndex = streamIndex;
|
||||
seekTimestamp = timestamp;
|
||||
if (lanes[0].index >= 0 && !skipAudio && std::isfinite(audioTarget)) {
|
||||
const double earliest = std::min(target, std::max(0.0, audioTarget - lanes[0].preroll));
|
||||
seekIndex = -1;
|
||||
seekTimestamp = static_cast<int64_t>((earliest + origin) * AV_TIME_BASE);
|
||||
}
|
||||
seeking = true;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
budget.changed.notify_all();
|
||||
@@ -183,6 +194,20 @@ public:
|
||||
std::lock_guard lock(budget.mutex);
|
||||
return failure;
|
||||
}
|
||||
// Whether an existing bounded forward fetch can supply a seek's missing packets.
|
||||
// Past history and targets outside the current ahead window require normal seek handling.
|
||||
bool readAheadPending(double target, double audioTarget = NAN) const {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
if (failure || seeking || ended || halted || waitingForRoom ||
|
||||
target < position || target > position + aheadSeconds) return false;
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
const auto &lane = lanes[slot];
|
||||
if (lane.index < 0 || (slot == 0 && skipAudio) || trackEnded(lane)) continue;
|
||||
const double laneTarget = slot == 0 && std::isfinite(audioTarget) ? audioTarget : target;
|
||||
if (laneTarget > lane.frontierEnd + lane.tick) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
double bufferedStart(int slot) const {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
if (!cacheSeeking || failure || seeking || (slot == 0 && skipAudio)) return NAN;
|
||||
@@ -249,6 +274,7 @@ private:
|
||||
std::atomic<int64_t> deadline{std::numeric_limits<int64_t>::max()};
|
||||
bool seeking = false, ended = false, halted = false, skipAudio = false;
|
||||
bool cacheSeeking = false, started = false, audioDropped = false;
|
||||
bool waitingForRoom = false;
|
||||
int seekIndex = -1, failure = 0;
|
||||
int64_t seekTimestamp = 0;
|
||||
double origin = 0, position = 0;
|
||||
@@ -460,10 +486,15 @@ private:
|
||||
}
|
||||
// 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) || makeRoom(lane, bytes);
|
||||
});
|
||||
if (!makeRoom(lane, bytes)) {
|
||||
waitingForRoom = true;
|
||||
playerWake.notify_one();
|
||||
budget.changed.wait(lock, [&] {
|
||||
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
|
||||
(slot == 0 && skipAudio) || makeRoom(lane, bytes);
|
||||
});
|
||||
waitingForRoom = false;
|
||||
}
|
||||
if (stoppingNow()) return;
|
||||
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
|
||||
if (slot == 0 && skipAudio) audioDropped = true;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
namespace playback {
|
||||
inline bool validNetworkURL(const char *text) {
|
||||
if (!text) return false;
|
||||
const std::string_view url(text);
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
for (unsigned char value : url)
|
||||
if (value <= 32 || value == 127 || value == '\\') return false;
|
||||
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
|
||||
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
|
||||
auto host = authority;
|
||||
std::string_view port;
|
||||
if (host.front() == '[') {
|
||||
const auto end = host.find(']');
|
||||
if (end == std::string_view::npos || end == 1) return false;
|
||||
if (end + 1 < host.size()) {
|
||||
if (host[end + 1] != ':') return false;
|
||||
port = host.substr(end + 2);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
host = host.substr(1, end - 1);
|
||||
std::array<char, INET6_ADDRSTRLEN> address{};
|
||||
if (host.size() >= address.size()) return false;
|
||||
std::memcpy(address.data(), host.data(), host.size());
|
||||
in6_addr parsed{};
|
||||
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
|
||||
} else {
|
||||
const auto colon = host.find(':');
|
||||
if (colon != std::string_view::npos) {
|
||||
port = host.substr(colon + 1);
|
||||
host = host.substr(0, colon);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
if (host.empty()) return false;
|
||||
for (unsigned char value : host)
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
|
||||
}
|
||||
unsigned number = 0;
|
||||
for (unsigned char value : port) {
|
||||
if (value < '0' || value > '9') return false;
|
||||
number = number * 10 + value - '0';
|
||||
if (number > 65535) return false;
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
inline bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
if (headers.size() > 65536) return false;
|
||||
std::size_t offset = 0;
|
||||
while (offset < headers.size()) {
|
||||
const auto end = headers.find("\r\n", offset);
|
||||
if (end == std::string_view::npos) return false;
|
||||
const auto line = headers.substr(offset, end - offset);
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string_view::npos || colon == 0) return false;
|
||||
for (unsigned char value : line.substr(0, colon))
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
|
||||
for (unsigned char value : line.substr(colon + 1))
|
||||
if (value < 32 || value == 127) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
inline std::string networkOrigin(const char *text) {
|
||||
if (!validNetworkURL(text)) return {};
|
||||
std::string_view url(text);
|
||||
const auto start = url.starts_with("https://") ? 8u : 7u;
|
||||
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
|
||||
for (char &value : authority)
|
||||
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
|
||||
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
|
||||
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
|
||||
else authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
|
||||
return std::string(url.substr(0, start)) + authority;
|
||||
}
|
||||
}
|
||||
+111
-79
@@ -18,6 +18,7 @@ extern "C" {
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
@@ -32,6 +33,7 @@ extern "C" {
|
||||
#include <arpa/inet.h>
|
||||
#include <vector>
|
||||
#include "network_input.hpp"
|
||||
#include "network_policy.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr int sampleRate = 48000;
|
||||
@@ -72,7 +74,7 @@ struct Track {
|
||||
uint64_t timestampOccurrence = 0;
|
||||
AVStream *stream() const { return format->streams[index]; }
|
||||
};
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close };
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close, Remote };
|
||||
struct Command {
|
||||
Action action;
|
||||
std::string path;
|
||||
@@ -88,6 +90,7 @@ struct Command {
|
||||
bool live = false;
|
||||
std::string mediaURL{};
|
||||
double initialPosition = 0;
|
||||
PlayerState remoteState{};
|
||||
};
|
||||
|
||||
void copyError(char *destination, const char *message) noexcept {
|
||||
@@ -99,73 +102,39 @@ int64_t presentationTimestamp(const AVFrame *frame) {
|
||||
frame->best_effort_timestamp : frame->pts;
|
||||
}
|
||||
|
||||
bool validNetworkURL(const char *text) {
|
||||
if (!text) return false;
|
||||
const std::string_view url(text);
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
for (unsigned char value : url)
|
||||
if (value <= 32 || value == 127 || value == '\\') return false;
|
||||
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
|
||||
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
|
||||
auto host = authority;
|
||||
std::string_view port;
|
||||
if (host.front() == '[') {
|
||||
const auto end = host.find(']');
|
||||
if (end == std::string_view::npos || end == 1) return false;
|
||||
if (end + 1 < host.size()) {
|
||||
if (host[end + 1] != ':') return false;
|
||||
port = host.substr(end + 2);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
host = host.substr(1, end - 1);
|
||||
std::array<char, INET6_ADDRSTRLEN> address{};
|
||||
if (host.size() >= address.size()) return false;
|
||||
std::memcpy(address.data(), host.data(), host.size());
|
||||
in6_addr parsed{};
|
||||
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
|
||||
} else {
|
||||
const auto colon = host.find(':');
|
||||
if (colon != std::string_view::npos) {
|
||||
port = host.substr(colon + 1);
|
||||
host = host.substr(0, colon);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
if (host.empty()) return false;
|
||||
for (unsigned char value : host)
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
|
||||
using playback::validNetworkURL;
|
||||
using playback::validHeaders;
|
||||
using playback::networkOrigin;
|
||||
|
||||
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
|
||||
int flags, AVDictionary **options) noexcept {
|
||||
try {
|
||||
const auto &input = *static_cast<playback::NetworkInput *>(format->opaque);
|
||||
// crypto forwards options to its underlying HTTP protocol; do not unwrap the
|
||||
// actual URL, since FFmpeg must still decrypt AES-128 segments itself.
|
||||
const char *resource = std::strncmp(url, "crypto+", 7) == 0 ||
|
||||
std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
|
||||
if (networkOrigin(resource) != input.headerOrigin) return AVERROR(EACCES);
|
||||
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
if (result >= 0) result = av_dict_set(options, "headers", input.requestHeaders.c_str(), 0);
|
||||
// FFmpeg 9's HTTP redirect loop retains custom headers. Reject every redirect
|
||||
// before any second request, including same-origin redirects, rather than leak.
|
||||
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
|
||||
if (result < 0) return result;
|
||||
return avio_open2(pb, url, flags, &format->interrupt_callback, options);
|
||||
} catch (const std::bad_alloc &) {
|
||||
return AVERROR(ENOMEM);
|
||||
} catch (...) {
|
||||
return AVERROR(EINVAL);
|
||||
}
|
||||
unsigned number = 0;
|
||||
for (unsigned char value : port) {
|
||||
if (value < '0' || value > '9') return false;
|
||||
number = number * 10 + value - '0';
|
||||
if (number > 65535) return false;
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
|
||||
bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
if (headers.size() > 65536) return false;
|
||||
std::size_t offset = 0;
|
||||
while (offset < headers.size()) {
|
||||
const auto end = headers.find("\r\n", offset);
|
||||
if (end == std::string_view::npos) return false;
|
||||
const auto line = headers.substr(offset, end - offset);
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string_view::npos || colon == 0) return false;
|
||||
for (unsigned char value : line.substr(0, colon))
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
|
||||
for (unsigned char value : line.substr(colon + 1))
|
||||
if (value < 32 || value == 127) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
return true;
|
||||
void redactedFFmpegLog(void *, int level, const char *, va_list) {
|
||||
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
|
||||
// FFmpeg diagnostics can interpolate signed URLs, cookies, keys and headers,
|
||||
// including at error level. Player::check still exposes operation + AVERROR.
|
||||
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg error (private details redacted)\n" :
|
||||
"FFmpeg warning (private details redacted)\n", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +196,7 @@ struct Player {
|
||||
bool previewShown = false;
|
||||
std::array<char, 512> error{};
|
||||
std::unique_ptr<playback::ProgressReporter> reporter;
|
||||
bool remoteReporting = false;
|
||||
std::thread worker;
|
||||
|
||||
Player() : worker([this] { run(); }) {}
|
||||
@@ -388,7 +358,7 @@ struct Player {
|
||||
}
|
||||
std::memcpy(state.error, error.data(), error.size());
|
||||
++state.revision;
|
||||
if (reporter && reportingInputsHealthy()) reporter->observe(state);
|
||||
if (reporter && !remoteReporting && reportingInputsHealthy()) reporter->observe(state);
|
||||
}
|
||||
|
||||
bool reportingInputsHealthy() const {
|
||||
@@ -397,7 +367,7 @@ struct Player {
|
||||
}
|
||||
|
||||
void reportBoundary() {
|
||||
if (!reporter || !reportingInputsHealthy()) return;
|
||||
if (!reporter || remoteReporting || !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{};
|
||||
@@ -521,20 +491,40 @@ struct Player {
|
||||
steadyAnchor = Clock::now();
|
||||
}
|
||||
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, bool live) {
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers,
|
||||
bool live, bool resolved) {
|
||||
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();
|
||||
input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input};
|
||||
if (resolved) {
|
||||
input.headerOrigin = networkOrigin(url.c_str());
|
||||
input.requestHeaders = headers;
|
||||
input.format->opaque = &input;
|
||||
input.format->io_open = resolvedIOOpen;
|
||||
}
|
||||
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);
|
||||
// Leave room for socket-timeout recovery within the reader's total 15-second deadline.
|
||||
if (result >= 0) result = av_dict_set(&options, "rw_timeout", live ? "15000000" : "5000000", 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);
|
||||
// Resume seekable inputs at their byte offset; never restart non-range streams or EOF.
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect", "1", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_on_network_error", "1", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_max_retries", "3", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_max", "3", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_total_max", "5", 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 && resolved) result = av_dict_set(&options, "http_persistent", "0", 0);
|
||||
// HLS persistent requests bypass io_open (and its origin/header policy).
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "http_multiple", "0", 0);
|
||||
// Resolved HLS may name genuine TS/fMP4 resources as images. Probe their
|
||||
// content instead of trusting suffixes; io_open still enforces every URL.
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "extension_picky", "0", 0);
|
||||
if (result < 0) {
|
||||
av_dict_free(&options);
|
||||
check(result, "Configuring network input");
|
||||
@@ -556,13 +546,15 @@ struct Player {
|
||||
|
||||
void open(const Command &command) {
|
||||
reportBoundary();
|
||||
remoteReporting = false;
|
||||
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, command.live);
|
||||
const bool resolved = command.mediaURL.starts_with("miruro:");
|
||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
||||
format = primary->format;
|
||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||
if (command.audioURL.empty()) {
|
||||
@@ -570,7 +562,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, command.live);
|
||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live, resolved);
|
||||
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");
|
||||
@@ -622,10 +614,12 @@ struct Player {
|
||||
}
|
||||
if (context->duration != AV_NOPTS_VALUE && context->duration > 0)
|
||||
measuredDuration = std::max(measuredDuration, static_cast<double>(context->duration) / AV_TIME_BASE);
|
||||
// Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks.
|
||||
// HLS time seeks reopen segments; byte-seek support on the initial
|
||||
// manifest response says nothing about its demuxer's seek capability.
|
||||
seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) &&
|
||||
(context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 :
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0);
|
||||
(context->iformat == av_find_input_format("hls") ||
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0 ||
|
||||
(context->pb && (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0));
|
||||
}
|
||||
// One origin for both inputs: independently zeroing them would erase real A/V offsets.
|
||||
origin = std::isfinite(first) ? first : 0;
|
||||
@@ -906,7 +900,9 @@ struct Player {
|
||||
}
|
||||
|
||||
void execute(const Command &command) {
|
||||
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
// Receiver facts precede the next open/close even when it supersedes decoder I/O.
|
||||
if (command.action == Action::Remote ? command.epoch != activeEpoch :
|
||||
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
|
||||
@@ -920,9 +916,18 @@ struct Player {
|
||||
case Action::Pause: pause(); break;
|
||||
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
||||
case Action::Step: step(command.direction); break;
|
||||
case Action::Remote:
|
||||
if (!remoteReporting) {
|
||||
reportBoundary();
|
||||
release();
|
||||
}
|
||||
remoteReporting = true;
|
||||
if (reporter) reporter->observe(command.remoteState);
|
||||
return;
|
||||
case Action::Close:
|
||||
reportBoundary();
|
||||
if (reporter) reporter->closeMedia();
|
||||
remoteReporting = false;
|
||||
release();
|
||||
break;
|
||||
}
|
||||
@@ -961,8 +966,10 @@ struct Player {
|
||||
}
|
||||
const double timestamp = framePts(track, decoded);
|
||||
const double tick = av_q2d(track.stream()->time_base);
|
||||
const bool contiguous = continuing && std::abs(timestamp - track.nextPts) <= tick + 1.0 / rate;
|
||||
// Quantized packet PTS and rounded resampler delay must not insert/drop PCM samples.
|
||||
// Some TS muxers round timestamps to milliseconds despite a 90 kHz timebase.
|
||||
// Allow two rounded endpoints of jitter; real gaps still insert silence.
|
||||
const bool contiguous = continuing &&
|
||||
std::abs(timestamp - track.nextPts) <= std::max(tick, 0.002) + 1.0 / rate;
|
||||
const int64_t delayBase = static_cast<int64_t>(rate) * sampleRate;
|
||||
pcmPts = contiguous ? resampleNextPts :
|
||||
timestamp - static_cast<double>(swr_get_delay(resampler, delayBase)) / delayBase;
|
||||
@@ -1345,6 +1352,15 @@ struct Player {
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (reporter) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (const auto &command : commands) {
|
||||
if (command.action == Action::Remote && command.epoch == activeEpoch) {
|
||||
reporter->observe(command.remoteState);
|
||||
remoteReporting = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
reportBoundary();
|
||||
if (reporter) reporter->closeMedia();
|
||||
release();
|
||||
@@ -1369,7 +1385,11 @@ bool submit(Player *player, Function function) noexcept {
|
||||
|
||||
extern "C" {
|
||||
Player *player_create(void) {
|
||||
try { return new Player; } catch (...) { return nullptr; }
|
||||
try {
|
||||
static std::once_flag logging;
|
||||
std::call_once(logging, [] { av_log_set_callback(redactedFFmpegLog); });
|
||||
return new Player;
|
||||
} catch (...) { return nullptr; }
|
||||
}
|
||||
bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
if (error) error[0] = '\0';
|
||||
@@ -1409,7 +1429,7 @@ bool player_open(Player *player, const char *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)) ||
|
||||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaIdentity(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;
|
||||
@@ -1443,6 +1463,18 @@ bool player_step_frame(Player *player, int direction) {
|
||||
bool player_close(Player *player) {
|
||||
return submit(player, [=] { return player->enqueue({Action::Close, {}}); });
|
||||
}
|
||||
bool player_report_remote_state(Player *player, const PlayerState *state) {
|
||||
if (!state || !std::isfinite(state->position) || state->position < 0 ||
|
||||
!std::isfinite(state->duration) || state->duration < 0 ||
|
||||
(state->status != PLAYER_PAUSED && state->status != PLAYER_PLAYING &&
|
||||
state->status != PLAYER_ENDED)) return false;
|
||||
const PlayerState snapshot = *state;
|
||||
return submit(player, [=] {
|
||||
Command command{Action::Remote, {}};
|
||||
command.remoteState = snapshot;
|
||||
return player->enqueue(std::move(command));
|
||||
});
|
||||
}
|
||||
bool player_set_volume(Player *player, float normalized0to1) {
|
||||
if (!std::isfinite(normalized0to1) || normalized0to1 < 0 || normalized0to1 > 1) return false;
|
||||
return submit(player, [=] {
|
||||
|
||||
@@ -53,7 +53,8 @@ typedef struct PlayerNetworkSource {
|
||||
const char *audio_headers;
|
||||
double duration;
|
||||
bool is_live;
|
||||
// Canonical YouTube webpage identity, never an extracted stream URL.
|
||||
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode>
|
||||
// identity, never an extracted stream/CDN URL.
|
||||
// NULL/empty means no reporting for this source.
|
||||
const char *media_url;
|
||||
// Initial restored position, applied before publishing ready state. Zero
|
||||
@@ -70,6 +71,9 @@ bool player_seek(Player *player, double seconds);
|
||||
// Pauses playback and selects the adjacent decoded presentation frame.
|
||||
bool player_step_frame(Player *player, int direction);
|
||||
bool player_close(Player *player);
|
||||
// Receiver-confirmed Cast facts replace decoder facts in progress reporting until
|
||||
// the next open/close. The local decoder must be paused before submitting them.
|
||||
bool player_report_remote_state(Player *player, const PlayerState *state);
|
||||
// Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes.
|
||||
// Rejects null handles and non-finite/out-of-range values without changing gain.
|
||||
bool player_set_volume(Player *player, float normalized0to1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef PERSONAL_PLAYER_MACOS_H
|
||||
#define PERSONAL_PLAYER_MACOS_H
|
||||
#include "player.h"
|
||||
#include "cast_media.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
@@ -69,11 +69,31 @@ bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
||||
if (validMediaURL(identity)) return true;
|
||||
constexpr std::string_view prefix = "miruro:";
|
||||
if (identity.substr(0, prefix.size()) != prefix) return false;
|
||||
const auto separator = identity.find(':', prefix.size());
|
||||
if (separator == std::string_view::npos) return false;
|
||||
const auto id = identity.substr(prefix.size(), separator - prefix.size());
|
||||
if (id.empty() || id.size() > 512) return false;
|
||||
for (char c : id) {
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
|
||||
}
|
||||
const auto episode = identity.substr(separator + 1);
|
||||
if (episode.empty() || episode.front() < '1' || episode.front() > '9') return false;
|
||||
for (char c : episode) if (c < '0' || c > '9') return false;
|
||||
int value = 0;
|
||||
const auto parsed = std::from_chars(episode.data(), episode.data() + episode.size(), value);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == episode.data() + episode.size() && value > 0;
|
||||
}
|
||||
|
||||
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");
|
||||
if (!validMediaIdentity(url)) {
|
||||
disable("Progress media identity is invalid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -144,11 +164,21 @@ bool ProgressReporter::emit(const char *event) noexcept {
|
||||
try {
|
||||
std::string record;
|
||||
record.reserve(256);
|
||||
record += "{\"version\":1,\"event\":\"";
|
||||
const bool miruro = mediaURL.compare(0, 7, "miruro:") == 0;
|
||||
record += miruro ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
|
||||
record += event;
|
||||
record += "\",\"media_url\":\"";
|
||||
record += mediaURL;
|
||||
record += "\",\"position_seconds\":";
|
||||
if (miruro) {
|
||||
const auto separator = mediaURL.find(':', 7);
|
||||
record += "\",\"media_identity\":{\"provider\":\"miruro\",\"media_id\":\"";
|
||||
record.append(mediaURL, 7, separator - 7);
|
||||
record += "\",\"episode\":";
|
||||
record.append(mediaURL, separator + 1, std::string::npos);
|
||||
record += "},\"position_seconds\":";
|
||||
} else {
|
||||
record += "\",\"media_url\":\"";
|
||||
record += mediaURL;
|
||||
record += "\",\"position_seconds\":";
|
||||
}
|
||||
appendNumber(record, snapshot.position);
|
||||
record += ",\"duration_seconds\":";
|
||||
if (snapshot.duration > 0) appendNumber(record, snapshot.duration);
|
||||
|
||||
@@ -20,6 +20,7 @@ public:
|
||||
ProgressReporter &operator=(const ProgressReporter &) = delete;
|
||||
|
||||
static bool validMediaURL(std::string_view url) noexcept;
|
||||
static bool validMediaIdentity(std::string_view identity) noexcept;
|
||||
void begin(const std::string &mediaURL, bool live = false) noexcept;
|
||||
void observe(const PlayerState &state) noexcept;
|
||||
void closeMedia() noexcept;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
- [x] add miruro streaming support
|
||||
- [x] investigate and fix audio when streaming from miruro - audio seems to crackle (not for youtube i think; only for miruros)
|
||||
- [x] add chrome cast support — implemented; verify playback, captions and A/V sync on a physical Cast device
|
||||
- [x] fix premature receiver-launch timeout: reports failure even though Chromecast subsequently connects and playback works
|
||||
- [x] fix TV volume slider jitter: receiver updates appear to compete with local volume adjustments
|
||||
- [x] investigate and reduce slow buffering after seeking in YouTube videos while casting, compared with local playback — reuse probed inputs and cache completed segments; confirm perceived improvement on TV
|
||||
- [x] fix Miruro Cast upstream overfetch: select HLS streams and share bounded video/audio packet buffering — verify sustained playback on TV
|
||||
- [x] styling/ui changes — implemented; verify layout and casting appearance in the app:
|
||||
- move the captions button in the controls left of the compact mode icon
|
||||
- place the Cast button between pin and settings in the top-right container; in compact mode, between exit compact mode and settings
|
||||
- don't leave and empty spot where the captions were supposed to be if captions aren't available; buttons should be moved over in that case.
|
||||
- the background is currently black in the player when casting (chrome cast), but we should follow the design language of the rest of the app.
|
||||
- replace the current chrome cast icon with the apple tv casting icon.
|
||||
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
|
||||
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)
|
||||
- [ ] evaluate playback optimization opportunities for YouTube and Miruro — measure benefits before changing behavior
|
||||
- [ ] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering
|
||||
- [ ] expand Cast H.264/AAC remux coverage with keyframe-aligned segments; preserve seek accuracy, timestamps and receiver compatibility
|
||||
- [ ] reuse decoder/encoder state during sequential Cast playback; reset on random seeks and preserve independent keyframes and AAC continuity
|
||||
- [ ] evaluate VideoToolbox hardware video decoding for native and Cast playback; retain software fallback for unsupported formats
|
||||
- [ ] benchmark cold start, cached/uncached seeks and sustained playback separately to prioritize the actual bottlenecks
|
||||
- do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs
|
||||
@@ -0,0 +1,164 @@
|
||||
# Handoff: Miruro playback in Terebi
|
||||
|
||||
## Status
|
||||
|
||||
Terebi now supports resolved Miruro HTTP(S) sources with per-source headers, external WebVTT and stable episode progress. Direct **`ssub` / `sun`** playback was exercised with a freshly resolved source: native video/audio advanced, default English subtitles loaded, and the model's cue text matched expected subtitles after forward and backward acknowledged seeks. The user also reported that visible playback appears to work; this is not a comprehensive visual synchronization sign-off.
|
||||
|
||||
Tubular integration is still required. Terebi's source/progress contracts below are implemented, not merely proposed; Tubular has not yet adopted them in this work.
|
||||
|
||||
## Ownership
|
||||
|
||||
- **Tubular:** discovery, episode/provider/language selection, fresh source resolution, source-document creation, session cleanup and watched-state decisions.
|
||||
- **Terebi:** native playback, Cast discovery/control and LAN delivery, request-header propagation, subtitle loading/selection/rendering, stable-identity bookmarks and acknowledged progress reporting.
|
||||
|
||||
No Miruro scraping, `strm.cx` embedding, cookies, impersonation or protection-bypass machinery was added to Terebi.
|
||||
|
||||
## Launch contract
|
||||
|
||||
```sh
|
||||
terebi --new-instance --source-file '/private/session/source.json' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
`--source-file` requires `--new-instance`; `--progress-file` is optional and retains its existing behavior. Relative paths resolve against the invoking shell's directory. Each launch creates an independent player; the CLI passes absolute paths to the GUI, not media URLs/header values in arguments.
|
||||
|
||||
Create a unique session directory with mode `0700`, write the source document with mode `0600`, and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report with mode `0600`; it rejects existing destinations and symlinks. Keep the source file unchanged and present for the player session. CLI success means macOS accepted the launch, not that media opened or playback completed. Tubular owns session-file cleanup.
|
||||
|
||||
### Source document, version 1
|
||||
|
||||
Example only: replace CDN URLs with freshly resolved resources, preserving complete addresses and tokens.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"url": "https://cdn.example/master.m3u8?token=fresh",
|
||||
"headers": {
|
||||
"Origin": "https://ultracloud.cc",
|
||||
"Referer": "https://ultracloud.cc/"
|
||||
},
|
||||
"identity": {
|
||||
"provider": "miruro",
|
||||
"media_id": "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw",
|
||||
"episode": 1
|
||||
},
|
||||
"title": "The Apothecary Diaries — episode 1",
|
||||
"subtitles": [{
|
||||
"url": "https://cdn.example/english.jpeg?token=fresh",
|
||||
"format": "vtt",
|
||||
"language": "en",
|
||||
"label": "English",
|
||||
"default": true,
|
||||
"headers": {
|
||||
"Origin": "https://ultracloud.cc",
|
||||
"Referer": "https://ultracloud.cc/"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
All shown fields except `title` are required. `headers: {}` and `subtitles: []` are valid; at most one subtitle may be default. Only `vtt` is supported for external subtitles in this pass.
|
||||
|
||||
Limits: source document 1 MiB; 32 subtitle tracks; 64 headers per resource; Miruro media ID up to 512 ASCII letters/digits/`-`/`_`; episode integer 1–2147483647. Invalid URLs, HTTP header names, case-insensitive duplicate header names and control characters are rejected with sanitized errors. Header values are not trimmed or rewritten.
|
||||
|
||||
### Projection from Tubular's Miruro response
|
||||
|
||||
Use the selected direct Sun server/provider from `src/miruro.rs`:
|
||||
|
||||
| Terebi field | Tubular resolved source |
|
||||
| --- | --- |
|
||||
| `url` | Selected `server.streams[].url`, unchanged |
|
||||
| `headers` | Selected server's header map, including Origin and Referer |
|
||||
| `identity.media_id` | Miruro opaque catalog ID, not AniList/MAL ID or slug |
|
||||
| `identity.episode` | Selected returned episode number used for source resolution, not absolute episode number or array index |
|
||||
| `subtitles[].url` | Selected provider subtitle's `file` |
|
||||
| `format`, `language`, `label`, `default` | Selected subtitle metadata; restrict this launch to VTT and at most one default |
|
||||
| `subtitles[].headers` | Explicit headers appropriate to that subtitle resource |
|
||||
|
||||
Subtitle headers **do not inherit** stream headers. The verified Sun smoke explicitly supplied its server headers to the English subtitle resource; do not assume this applies to other providers or resources. `identity.provider` is the catalog namespace `miruro`, not the playback provider `sun`.
|
||||
|
||||
## Propagation and subtitle behavior
|
||||
|
||||
- Stream headers reach the master, nested variants, media segments, AES-128 keys and fMP4 init resources. Relative references use native HLS resolution. Existing query tokens are preserved; Terebi does not invent query inheritance or provider-specific URL rewrites.
|
||||
- Resolved media requests must stay on the initial stream's origin: scheme, host and effective port. **All media redirects, including same-origin redirects, are rejected before a second request.** This conservative policy prevents FFmpeg from forwarding supplied private headers to another destination. Cross-origin HLS resources are not supported in this contract.
|
||||
- Sun serves MPEG-TS segments as `.jpg` / `image/jpeg`. Resolved playback probes actual media content instead of enforcing filename-extension matching; it does not change the URLs. HTTP(S)-only, origin and redirect restrictions remain in force.
|
||||
- External VTT classification uses explicit format and content, not filename extension or MIME type. `.jpeg` subtitle URLs work. Labels/languages appear in the captions menu; the declared default is selected, and the user can choose Off or another track.
|
||||
- Cues follow acknowledged player position, including seeking backwards. Zero-duration cues are permanently inactive rather than invalidating the track. Rendering is plain text; WebVTT CSS, positioning and typographic styling are not applied.
|
||||
- Subtitle HTTP requests use their own headers. Same-origin redirects retain those headers; cross-origin redirects are rejected. Responses are capped at 4 MiB, with 15-second request and 20-second resource timeouts.
|
||||
- Media errors remain visible with the operation/native error. Subtitle failures appear separately beside the captions menu, including when other controls are hidden. FFmpeg diagnostics redact private details; source/subtitle errors do not interpolate URLs or header values.
|
||||
- Casting uses the same episode identity and progress schema; receiver-confirmed positions replace paused local-decoder facts. The Mac relays media/VTT without exposing source URLs or credentials to the receiver. See [Chromecast controls, transport limits and verification](../README.md#chromecast); physical receiver playback/captions remain unverified.
|
||||
|
||||
YouTube's existing launch, version-1 progress and transport behavior remain supported.
|
||||
|
||||
## Progress contract and Tubular change
|
||||
|
||||
YouTube records remain **version 1** with canonical `media_url`. Miruro records are **version 2**, with structured episode identity and no CDN address:
|
||||
|
||||
```json
|
||||
{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"OERe_a0YS4h-DlsBDOeXwX7HOO6316tw","episode":1},"position_seconds":123.5,"duration_seconds":1500.0,"completion_eligible":true}
|
||||
```
|
||||
|
||||
`event` is `progress`, `ended` or `closed`. Position is finite, nonnegative and acknowledged by playback, not a pending seek/slider target. Duration is positive and finite or `null`. Completion eligibility remains false for live/non-seekable media, unknown duration and audio-only sources.
|
||||
|
||||
Reporting includes ready state after restoration, approximately five-second playback intervals, acknowledged pause/seek transitions, the first eligible 95% crossing, native end and final close/quit snapshots. Launch success and window closure do not establish completion. Consumers must tolerate duplicates, partial trailing lines and abrupt termination; a `closed` record is not guaranteed.
|
||||
|
||||
**Required in Tubular `src/video_state.rs`:** retain canonical YouTube/version-1 validation, add version-2 validation against the episode identity expected for that launched session, and key Miruro watched state by `(miruro, opaque media ID, episode)`. Never use stream URL, token, slug or selected playback provider as identity.
|
||||
|
||||
Retain the current completion decision for matching identities:
|
||||
|
||||
```text
|
||||
completion_eligible
|
||||
&& duration_seconds > 0
|
||||
&& position_seconds / duration_seconds >= 0.95
|
||||
```
|
||||
|
||||
Handle `null` duration as ineligible. This measures position, not viewing coverage; seeking near the end can satisfy it. Terebi also uses stable episode identity for its own resume bookmarks.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
| Scenario | Observed result |
|
||||
| --- | --- |
|
||||
| Explicit fresh direct Sun native smoke | Video/audio decode and advancing playback; default English VTT; expected cue text after forward/backward acknowledged seeks |
|
||||
| Live Sun audio continuity | Dragon Ball episode 1: actual Core Audio PCM diverged from FFmpeg after about 0.6 seconds before the fix; after tolerating millisecond-scale timestamp jitter, all 960,008 stereo frames compared across twenty seconds and multiple HLS segments matched exactly |
|
||||
| Audio regression | Deterministic AAC/MPEG-TS timestamp-jitter fixture failed before the shared continuity fix and matched FFmpeg exactly afterward; existing authored-gap, resampling, gain and stepped-resume checks passed |
|
||||
| Local resolved playback model | Independent stream/subtitle headers, nested playlist tokens, video/audio playback, cue timing and stable v2 progress |
|
||||
| Local native network checks | Encrypted MPEG-TS and fMP4; image-suffixed resources; headers on master/variant/segment/key/init; seeks/frame steps; redirect/foreign-origin isolation; diagnostic redaction; existing YouTube redirects |
|
||||
| Actual CLI-to-GUI handoff | Two independent Miruro episode windows; authenticated encrypted HLS; resource-specific default VTT; v2 progress and quit draining |
|
||||
| Full `make check-startup` rerun | Passed, including held-key suppression, YouTube progress/resume/quality/replacement/end and independent launches |
|
||||
| Other exercised checks | CLI, external subtitles, progress persistence, JSONL reporting, YouTube resolver and system playback passed |
|
||||
| Visible-app observation | User reported playback appears to work; comprehensive visual subtitle/seek synchronization sign-off remains limited |
|
||||
|
||||
Earlier startup runs failed held-Space and YouTube end-transition assertions. The user reported possible manual interaction. The full rerun passed without playback-code changes; causality of the earlier failures was not established. No full aggregate `make check` pass is claimed.
|
||||
|
||||
### Reproduction commands from Terebi
|
||||
|
||||
```sh
|
||||
make check-cli check-subtitles check-resolved check-network check-progress check-reporting
|
||||
make check-youtube check-media
|
||||
make check-startup
|
||||
```
|
||||
|
||||
For only the actual Miruro launch scenario:
|
||||
|
||||
```sh
|
||||
TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup
|
||||
```
|
||||
|
||||
Default checks use local fixtures and do not contact Miruro. For an **explicit live smoke**, resolve fresh in Tubular, project the private source document above, then run:
|
||||
|
||||
```sh
|
||||
make .build/resolved-playback-check
|
||||
.build/resolved-playback-check '/absolute/private/session/source.json'
|
||||
```
|
||||
|
||||
This checks native decode and acknowledged subtitle/seek state, not visual appearance or perceived A/V synchronization. Use the production launch command for that human check. Tubular's existing `cargo run --locked --example miruro_probe` verifies source resolution/master/subtitle retrieval, not Terebi playback.
|
||||
|
||||
## Remaining work and limits
|
||||
|
||||
1. Tubular must create the source document, launch Terebi with it and implement v2 progress consumption; no Tubular implementation files were changed by this work.
|
||||
2. Confirm this identity/reporting contract on the Tubular side; external adoption is not yet agreement.
|
||||
3. For end-to-end release verification, check English subtitle appearance and synchronization in the visible app before/after seeking, using Tubular's actual launch path.
|
||||
4. Sources requiring media redirects or cross-origin HLS resources will fail explicitly under the current conservative policy. Expired sources must be freshly resolved by Tubular; Terebi does not refresh automatically.
|
||||
|
||||
Other providers' proxies/HLS rewrites, iframe providers, downloads, thumbnails, intro/outro controls, embedded captions and additional external-subtitle formats remain out of scope.
|
||||
|
||||
The implementation and this handoff are local; no remote publication or Tubular issue/comment update was performed.
|
||||
Reference in New Issue
Block a user