feat: visualize retained network playback buffer

This commit is contained in:
2026-10-11 02:54:01 +02:00
parent 4d4c3e6b0a
commit 5087a0a0f0
8 changed files with 326 additions and 39 deletions
+146 -23
View File
@@ -14,6 +14,29 @@ static void require(bool condition, const char *message) {
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
}
static bool buffered(PlayerState state) {
return state.buffered_end > state.buffered_start;
}
static void noBuffer(PlayerState state, const char *message) {
require(state.buffered_start == 0 && state.buffered_end == 0, message);
}
static void validBuffer(PlayerState state) {
require(std::isfinite(state.buffered_start) && std::isfinite(state.buffered_end),
"consumer receives finite buffered timestamps");
if (state.status == PLAYER_EMPTY || state.status == PLAYER_OPENING ||
state.status == PLAYER_ERROR || state.status == PLAYER_ENDED || state.seek_pending) {
noBuffer(state, "unavailable/opening/error/ended/pending timeline has no retained range");
}
if (state.buffered_start == 0 && state.buffered_end == 0) return;
require(std::isfinite(state.duration) && state.duration > 0 &&
state.buffered_start >= 0 && buffered(state) && state.buffered_end <= state.duration,
"retained range is nonempty and bounded by finite media duration");
require(std::abs(state.buffered_start - state.position) < .00001,
"retained range follows the authoritative playback position, not a loaded prefix");
}
// Only these deadlines use elapsed time: a stuck command/read must fail the check,
// not hang CI. Playback assertions use fixture presentation timestamps and events.
static PlayerState until(Player *player, const std::function<bool(PlayerState)> &ready,
@@ -21,6 +44,7 @@ static PlayerState until(Player *player, const std::function<bool(PlayerState)>
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (std::chrono::steady_clock::now() < deadline) {
auto state = player_get_state(player);
validBuffer(state);
if (state.status == PLAYER_ERROR && !expectError) {
std::fprintf(stderr, "FAIL %s: %s\n", scenario, state.error);
std::exit(1);
@@ -29,8 +53,9 @@ static PlayerState until(Player *player, const std::function<bool(PlayerState)>
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
}
auto state = player_get_state(player);
std::fprintf(stderr, "FAIL %s: command deadline (status=%d position=%.4f duration=%.4f pending=%d buffering=%d)\n",
scenario, state.status, state.position, state.duration, state.seek_pending, state.buffering);
std::fprintf(stderr, "FAIL %s: command deadline (status=%d position=%.4f duration=%.4f pending=%d buffering=%d retained=%.4f..%.4f)\n",
scenario, state.status, state.position, state.duration, state.seek_pending, state.buffering,
state.buffered_start, state.buffered_end);
std::exit(1);
}
@@ -64,6 +89,7 @@ static NSDictionary *control(const std::string &base, const std::string &path) {
struct GateState {
bool entered, released;
uint64_t waiting, requests, ranges, bytes, total;
double audioEnd, videoEnd;
};
static GateState gateState(const std::string &base, const std::string &token) {
@@ -71,7 +97,8 @@ static GateState gateState(const std::string &base, const std::string &token) {
return {[state[@"entered"] boolValue], [state[@"released"] boolValue],
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
[state[@"total"] unsignedLongLongValue]};
[state[@"total"] unsignedLongLongValue],
[state[@"prefix_ends"][@"audio"] doubleValue], [state[@"prefix_ends"][@"video"] doubleValue]};
}
static void stillBlocked(const std::string &base, const std::string &token) {
@@ -106,6 +133,7 @@ static void closed(Player *player) {
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");
noBuffer(state, "close clears retained network coverage");
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network close");
}
@@ -113,6 +141,7 @@ static PlayerState seekCompleted(Player *player, double target, PlayerStatus sta
auto revision = player_get_state(player).revision;
require(player_seek(player, target), "network seek accepted");
auto pending = player_get_state(player);
validBuffer(pending);
require(pending.seek_pending || std::abs(pending.position - target) < .03,
"network seek remains pending until target is authoritative");
return until(player, [=](auto s) {
@@ -144,6 +173,7 @@ static void transport(Player *player, MTKView *view, const std::string &base, bo
[view draw];
require(player_seek(player, .8) && player_seek(player, 1.7), "queued seeks accepted on both inputs");
auto pending = player_get_state(player);
validBuffer(pending);
require(pending.seek_pending || std::abs(pending.position - 1.7) < .03,
"earlier seek cannot acknowledge final split-input seek");
until(player, [](auto s) {
@@ -166,6 +196,7 @@ static void transport(Player *player, MTKView *view, const std::string &base, bo
static PlayerState step(Player *player, int direction, double pts) {
auto revision = player_get_state(player).revision;
require(player_step_frame(player, direction), "split-source adjacent frame step accepted");
validBuffer(player_get_state(player));
auto state = until(player, [revision](auto s) {
return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED;
}, "split frame-step acknowledgement");
@@ -201,9 +232,10 @@ static void frameSteps(Player *player, MTKView *view, const std::string &base) {
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause split VFR after resume");
seekCompleted(player, 1.5, PLAYER_PAUSED);
require(player_play(player), "play past independent video EOF");
auto beyondVideo = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.2; },
"audio continues after independent video EOF");
require(beyondVideo.position < 3.9, "video EOF does not end longer audio source");
auto beyondVideo = until(player, [](auto s) {
return s.status == PLAYER_PLAYING && s.position > 2.2 && buffered(s);
}, "audio retains playable coverage after independent video EOF");
require(beyondVideo.position < 3.9, "video EOF does not end or cap longer audio coverage");
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer audio input drains");
require(ended.position >= 3.9 && ended.position < 4.2, "split EOF follows last audio sample");
require(player_close(player), "close split stepped source");
@@ -215,8 +247,9 @@ static void frameSteps(Player *player, MTKView *view, const std::string &base) {
require(player_play(player), "queued play with shorter audio");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "asymmetric split autoplay");
seekCompleted(player, 1.5, PLAYER_PLAYING);
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.3; },
"video continues after independent audio EOF");
until(player, [](auto s) {
return s.status == PLAYER_PLAYING && s.position > 2.3 && buffered(s);
}, "video retains playable coverage after independent audio EOF");
require(player_pause(player), "pause in independent audio tail");
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause longer video after audio EOF");
seekCompleted(player, 3.2, PLAYER_PAUSED);
@@ -250,15 +283,21 @@ static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
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; }, "live-flag open");
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"live-flag source retains media before local replacement");
require(!live.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1) &&
!player_step_frame(player, -1), "live source rejects seeks and both frame-step directions");
require(player_open(player, (fixtures + "/video.mp4").c_str()), "ordinary local source replaces network live source");
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "local replacement");
require(local.can_seek && !local.buffering && local.has_video && !local.has_audio && !local.seek_pending,
"local replacement restores seekability and clears network flags");
noBuffer(local, "local replacement does not inherit network buffered coverage");
require(std::abs(player_get_volume(player) - .37f) < .00001f, "local replacement keeps volume");
seekCompleted(player, 1.5, PLAYER_PAUSED);
noBuffer(seekCompleted(player, 1.5, PLAYER_PAUSED), "ordinary local seek never reports network coverage");
require(player_play(player), "play ordinary local replacement");
local = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.6; },
"local playback after replacing a buffered network source");
noBuffer(local, "ordinary local playback never reports network coverage");
require(player_close(player), "close local replacement");
closed(player);
require(openNetwork(player, base, "/no-range/streaming.mp4", "", 30), "non-range HTTP open");
@@ -280,16 +319,78 @@ static void blockedOpening(Player *player, const std::string &base, const std::s
}, "opening response-body read underway");
}
static double prefixEnd(GateState gate, bool splitAudio, bool splitVideo) {
return splitAudio ? gate.audioEnd : splitVideo ? gate.videoEnd : std::fmin(gate.audioEnd, gate.videoEnd);
}
static void prefixBound(PlayerState state, GateState gate, bool splitAudio = false, bool splitVideo = false) {
const double end = prefixEnd(gate, splitAudio, splitVideo);
require(end > 0 && end < 30, "gated thirty-second fixture withholds known presentation timestamps");
require(!buffered(state) || state.buffered_end <= end + .002,
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
}
static void bufferCoverage(Player *player, const std::string &base) {
require(openNetwork(player, base, "/media/streaming.mp4", "", 30),
"open long network fixture for bounded retained coverage");
auto initial = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"paused network source exposes actual retained media");
require(initial.buffered_end < initial.duration,
"bounded queues do not report an entire downloaded movie as retained");
require(player_play(player), "play to advance retained network coverage");
until(player, [=](auto s) {
return s.status == PLAYER_PLAYING && s.position > initial.buffered_end && buffered(s) &&
s.buffered_end > initial.buffered_end;
}, "retained horizon advances with real playback and refills");
require(player_close(player), "close advancing buffered source");
closed(player);
const std::string token = "buffer-release";
require(openNetwork(player, base, "/stall/" + token + "/streaming.mp4", "", 30),
"open event-gated retained coverage");
auto paused = until(player, [&](auto s) {
auto gate = gateState(base, token);
if (gate.entered) prefixBound(s, gate);
return s.status == PLAYER_PAUSED && buffered(s) && gate.entered && gate.waiting > 0;
}, "paused retained horizon is bounded by supplied fixture timestamps");
const auto gate = gateState(base, token);
prefixBound(paused, gate);
stillBlocked(base, token);
require(player_play(player), "play gated retained prefix");
until(player, [&](auto s) {
prefixBound(s, gate);
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s);
}, "gated prefix has real playable-ahead coverage");
until(player, [&](auto s) {
prefixBound(s, gate);
return s.status == PLAYER_PLAYING && s.buffering;
}, "gated coverage cannot advance into unavailable packets");
const double suppliedEnd = prefixEnd(gate, false, false);
stillBlocked(base, token);
control(base, "/control/release/" + token);
until(player, [=](auto s) {
return s.status == PLAYER_PLAYING && !s.buffering && buffered(s) && s.buffered_end > suppliedEnd;
}, "released server gate advances retained coverage beyond withheld timestamps");
require(player_close(player), "close released buffered source");
closed(player);
std::puts("PASS retained coverage: bounded queues/advancing clock/stalled timestamp bounds/B-frame dependencies/gate-release refill");
}
static void blockedPlayback(Player *player, const std::string &base, const std::string &token,
bool splitAudio = false) {
bool splitAudio = false, bool splitVideo = false) {
auto stalled = "/stall/" + token + "/streaming.mp4";
require(openNetwork(player, base, splitAudio ? "/media/streaming.mp4" : stalled,
splitAudio ? stalled : "", 30), "open bounded streaming prefix");
splitAudio ? stalled : splitVideo ? "/media/streaming.mp4" : "", 30),
"open bounded streaming prefix");
require(player_play(player), "queue play before gated source opens");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; },
"stream starts before entire file arrives");
until(player, [&](auto s) {
auto gate = gateState(base, token);
if (gate.entered) prefixBound(s, gate, splitAudio, splitVideo);
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s) && gate.entered;
}, "stream retains playable media before entire file arrives");
until(player, [&](auto s) {
auto gate = gateState(base, token);
if (gate.entered) prefixBound(s, gate, splitAudio, splitVideo);
return s.status == PLAYER_PLAYING && s.buffering && gate.entered && gate.waiting > 0 && !gate.released;
}, "real demux read blocks at server gate");
auto gate = gateState(base, token);
@@ -297,28 +398,42 @@ static void blockedPlayback(Player *player, const std::string &base, const std::
}
static void cancellation(Player *player, const std::string &base, const std::string &fixtures) {
for (bool splitAudio : {false, true}) {
const std::string token = splitAudio ? "split-playing-seek" : "pause-seek";
blockedPlayback(player, base, token, splitAudio);
if (!splitAudio) {
for (int input : {0, 1, 2}) {
const bool splitAudio = input == 1, splitVideo = input == 2;
const std::string token = splitAudio ? "split-playing-seek" : splitVideo ? "split-video-seek" : "pause-seek";
blockedPlayback(player, base, token, splitAudio, splitVideo);
if (!splitAudio && !splitVideo) {
require(player_pause(player), "pause while actual network read is blocked");
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause does not wait for network read");
stillBlocked(base, token);
require(std::abs(player_get_state(player).position - paused.position) < .0001, "buffered pause freezes native clock");
}
seekCompleted(player, 28, splitAudio ? PLAYER_PLAYING : PLAYER_PAUSED);
seekCompleted(player, 28, (splitAudio || splitVideo) ? PLAYER_PLAYING : PLAYER_PAUSED);
stillBlocked(base, token);
auto afterSeek = gateState(base, token);
require(afterSeek.requests > 1 && afterSeek.ranges > 1, "seek replaces stalled read with an actual new byte-range request");
require(player_play(player), "resume at seek target with stale read still blocked");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1; },
"resume after stalled-read seek");
until(player, [](auto s) {
return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1 && buffered(s);
}, "retained coverage follows new stalled-read seek target");
stillBlocked(base, token);
require(player_pause(player), "pause high-timestamp coverage before backward seek");
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"retain high-timestamp media before invalidating it");
seekCompleted(player, .5, PLAYER_PAUSED);
auto rewound = until(player, [](auto s) {
return s.status == PLAYER_PAUSED && !s.seek_pending && buffered(s);
}, "backward seek rebuilds retained coverage at the new target");
require(rewound.buffered_end < 28,
"backward seek drops the old high-water horizon instead of retaining distant media");
stillBlocked(base, token);
require(player_close(player), "close resumed formerly stalled source");
closed(player);
std::puts(splitAudio
? "PASS stalled secondary audio reader: split playing seek/both-input acknowledgement/new range/resume without server release"
: "PASS stalled demux read: buffering/bounded prefix/pause/paused seek acknowledgement/new range/resume without server release");
? "PASS stalled secondary audio: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
: splitVideo
? "PASS stalled primary video: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
: "PASS stalled combined input: bounded prefix/pause/seek invalidation/new-target coverage/backward high-water clearing");
}
blockedOpening(player, base, "opening-close");
@@ -331,6 +446,7 @@ static void cancellation(Player *player, const std::string &base, const std::str
"local replacement cancels blocked opening read");
require(local.can_seek && !local.buffering && !local.seek_pending && local.position == 0,
"stale opening read cannot publish over replacement timeline");
noBuffer(local, "local audio replacement clears retained network media");
stillBlocked(base, "opening-replace");
require(player_close(player), "close audio-only replacement");
closed(player);
@@ -347,6 +463,8 @@ static void cancellation(Player *player, const std::string &base, const std::str
"network replacement cancels blocked playing read");
require(replacement.position == 0 && !replacement.buffering && !replacement.seek_pending && replacement.can_seek,
"replacement ignores stale stream data and buffering state");
require(!buffered(replacement) || replacement.buffered_end <= 4.2,
"new network source cannot retain the previous thirty-second horizon");
stillBlocked(base, "playing-replace");
require(player_close(player), "close split network replacement");
closed(player);
@@ -354,6 +472,10 @@ static void cancellation(Player *player, const std::string &base, const std::str
}
static void failures(Player *player, const std::string &base) {
require(openNetwork(player, base, "/media/streaming.mp4", "", 30),
"open retained network media before failure replacement");
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"failure replacement starts with retained coverage to invalidate");
require(openNetwork(player, base, "/media/av.mp4", "", 4, false, false), "header-denied request accepted asynchronously");
auto denied = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "required header denial", true);
require(denied.error[0] && !denied.has_audio && !denied.has_video && !denied.can_seek &&
@@ -393,6 +515,7 @@ int main(int argc, char **argv) {
frameSteps(player.get(), view, base);
nativeHLS(player.get(), view, base);
liveAndReplacement(player.get(), base, fixtures);
bufferCoverage(player.get(), base);
cancellation(player.get(), base, fixtures);
failures(player.get(), base);
blockedPlayback(player.get(), base, "destroy-playing");
+38 -4
View File
@@ -14,8 +14,9 @@ from urllib.parse import parse_qs, unquote, urlsplit
class _Gate:
def __init__(self, total: int):
def __init__(self, total: int, prefix_ends: dict[str, float]):
self.total = total
self.prefix_ends = prefix_ends
self.entered = threading.Event()
self.release = threading.Event()
self.claimed = False
@@ -45,6 +46,7 @@ class StreamingServer:
self.lock = threading.Lock()
self.gates: dict[str, _Gate] = {}
self.stats: dict[str, dict[str, int]] = {}
self.prefixes: dict[tuple[str, int], dict[str, float]] = {}
self.stopping = threading.Event()
owner = self
@@ -103,6 +105,35 @@ class StreamingServer:
end = min(int(last), size - 1) if last else size - 1
return (start, end) if 0 <= start <= end < size else None
def _prefix_ends(self, name: str, offset: int) -> dict[str, float]:
"""Timestamp upper bounds, never a byte fraction interpreted as playback time."""
key = (name, offset)
if key not in self.prefixes:
probe = subprocess.run(
["ffprobe", "-v", "error", "-show_packets", "-show_streams", "-show_format",
"-show_entries", "format=start_time:stream=index,codec_type:packet=stream_index,pts_time,duration_time,pos,size",
"-of", "json", str(self.fixtures / name)],
capture_output=True, text=True, check=True, timeout=30,
)
metadata = json.loads(probe.stdout)
tracks = {stream["index"]: stream["codec_type"] for stream in metadata["streams"]}
origin = float(metadata.get("format", {}).get("start_time", 0))
ends = {"audio": 0.0, "video": 0.0}
first_missing = {"audio": float("inf"), "video": float("inf")}
for packet in metadata["packets"]:
kind = tracks.get(packet["stream_index"])
if kind not in ends or "pts_time" not in packet or "pos" not in packet:
continue
# A packet crossing the gate is not playable, even if its header arrived.
if int(packet["pos"]) + int(packet["size"]) <= offset:
end = float(packet["pts_time"]) - origin + float(packet.get("duration_time", 0))
ends[kind] = max(ends[kind], end)
else:
first_missing[kind] = min(first_missing[kind], max(0, float(packet["pts_time"]) - origin))
# A reordered reference packet's later PTS cannot bridge an undelivered B-frame.
self.prefixes[key] = {kind: min(end, first_missing[kind]) for kind, end in ends.items()}
return self.prefixes[key]
def _get(self, request):
parsed = urlsplit(request.path)
parts = unquote(parsed.path).strip("/").split("/")
@@ -116,13 +147,13 @@ class StreamingServer:
gate = self.gates.get(parts[2])
if gate is None:
body = {"entered": False, "released": False, "waiting": 0, "requests": 0,
"ranges": 0, "bytes": 0, "total": 0}
"ranges": 0, "bytes": 0, "total": 0, "prefix_ends": {}}
else:
if parts[1] == "release":
gate.release.set()
body = {"entered": gate.entered.is_set(), "released": gate.release.is_set(),
"waiting": gate.waiting, "requests": gate.requests, "ranges": gate.ranges,
"bytes": gate.bytes, "total": gate.total}
"bytes": gate.bytes, "total": gate.total, "prefix_ends": gate.prefix_ends}
self._reply(request, 200, json.dumps(body).encode())
return
@@ -180,7 +211,10 @@ class StreamingServer:
self._reply(request, 400, b"Invalid gate offset", "text/plain")
return
with self.lock:
gate = self.gates.setdefault(token, _Gate(size))
if token not in self.gates:
prefix = self._prefix_ends(name, gate_at) if mode == "stall" else {}
self.gates[token] = _Gate(size, prefix)
gate = self.gates[token]
gate.requests += 1
gate.ranges += int(range_header is not None)
# Only the original prefix connection is gated: a seek can create a new
+7
View File
@@ -67,7 +67,14 @@ 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 }
// Closing must hide the last polled retained range before the next state poll.
var retainedSnapshot = model.state
retainedSnapshot.buffered_start = retainedSnapshot.position
retainedSnapshot.buffered_end = min(retainedSnapshot.duration, retainedSnapshot.position + 1)
model.applyState(retainedSnapshot)
require(model.bufferedRange != nil, "loaded selection exposes retained media")
model.closeFile()
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
"accepted close releases system playback immediately")
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
+6 -4
View File
@@ -64,6 +64,8 @@ Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a Yo
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
The normal-mode seek bar highlights the **buffered range** in lighter gray ahead of the playhead. Hover the bar for the buffered-through timestamp and seconds retained ahead; VoiceOver includes the same information. This is a conservative playable range constrained by both required audio and video inputs, including decoded/output buffers—not a percentage downloaded or a persistent cache of the whole video. It resets on seeks, quality/source changes, and close. Local playback and live/unknown-duration media do not display it. On long videos the segment can be very short because the retained buffers are bounded.
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.
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency.
@@ -112,9 +114,9 @@ Only implemented actions have controls; there are no placeholder playlist, subti
## Where to change things
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, and live status. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, and live status. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `buffered_start`/`buffered_end` report a conservative retained playable range in normalized seconds, constrained by both tracks; unavailable/local/pending ranges are `0, 0`, and a nonempty range starts at `position` and ends no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes both selected inputs' old timeline state before resuming.
- `Sources/Core/network_input.hpp`: network-only demux readers with bounded encoded packet queues, asynchronous coordinated seeks, fifteen-second I/O deadlines, and interruption on seek/replacement/close/destruction. Remote input protocols exclude local-file access through manifests.
- `Sources/Core/network_input.hpp`: network-only demux readers with bounded encoded packet queues, constant-time timestamp frontiers (conservative for reordered video), asynchronous coordinated seeks, fifteen-second I/O deadlines, and interruption on seek/replacement/close/destruction. Remote input protocols exclude local-file access through manifests.
- `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely.
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, YouTube quality switching with in-memory position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control.
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source.
@@ -139,7 +141,7 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent.
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. Both new checks are included in `make check`.
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. Retained-range checks cover bounded growth, FFprobe-derived supplied-packet timestamp bounds and B-frame dependencies, both split-track constraints, explicit gate-release refill, forward/backward seek invalidation, shorter-track EOF tails, and clearing on close/error/local replacement. Both network and YouTube checks are included in `make check`.
`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, and isolated preference domains. It is included in `make check` and does not touch real playback history.
@@ -147,7 +149,7 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified.
`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, and Now Playing cleanup on close/error/shutdown. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies that the playing process retains a real registered app PID and bundle identity—the prerequisite for third-party Now Playing discovery. It is included in `make check`; it does not require Vorssaint, show a playback window, or touch real playback history. Cold Finder/CLI opens, warm local/YouTube opens, saved-position restoration, same-window reuse, and Vorssaint's adapter discovery were additionally smoke-checked through the real GUI.
+29 -3
View File
@@ -87,6 +87,13 @@ final class PlayerModel: ObservableObject {
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
var bufferedRange: ClosedRange<Double>? {
guard fileName != nil, canSeek, !seekPending, !state.seek_pending,
state.buffered_start.isFinite, state.buffered_end.isFinite,
state.buffered_start >= 0, state.buffered_end > state.buffered_start,
state.buffered_end <= duration else { return nil }
return state.buffered_start...state.buffered_end
}
var errorText: String? {
if let commandError { return commandError }
guard !isBusy, state.status == PLAYER_ERROR else { return nil }
@@ -592,6 +599,7 @@ private struct PlaybackSlider: NSViewRepresentable {
let range: ClosedRange<Double>
var volume = false
var enabled = true
var bufferedRange: ClosedRange<Double>?
var onEditingChanged: (Bool) -> Void = { _ in }
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -614,6 +622,10 @@ private struct PlaybackSlider: NSViewRepresentable {
slider.doubleValue = value
slider.isEnabled = enabled
slider.editingChanged = onEditingChanged
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
cell.bufferedRange = bufferedRange
slider.needsDisplay = true
}
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
}
@@ -643,6 +655,7 @@ private struct PlaybackSlider: NSViewRepresentable {
final class Cell: NSSliderCell {
var volume = false
var bufferedRange: ClosedRange<Double>?
override func drawBar(inside rect: NSRect, flipped: Bool) {
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
width: max(0, rect.width - knobThickness), height: 3)
@@ -653,6 +666,14 @@ private struct PlaybackSlider: NSViewRepresentable {
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
NSColor.systemOrange.withAlphaComponent(0.85).setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
} else if let bufferedRange, maxValue > minValue {
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
let scale = bar.width / (maxValue - minValue)
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
width: (upper - lower) * scale, height: bar.height)
NSColor.white.withAlphaComponent(0.4).setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
}
override func drawKnob(_ rect: NSRect) {
@@ -832,7 +853,11 @@ private struct PlayerContent: View {
}
private var playbackControls: some View {
VStack(spacing: 8) {
let buffered = model.bufferedRange
let bufferedStatus = buffered.map {
"Buffered through \(time($0.upperBound)) — \(String(format: "%.1f", $0.upperBound - $0.lowerBound)) seconds ahead"
}
return VStack(spacing: 8) {
HStack(spacing: 0) {
HStack(spacing: 4) {
iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill",
@@ -875,12 +900,13 @@ private struct PlayerContent: View {
HStack(spacing: 10) {
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
enabled: model.canSeek, onEditingChanged: { editing in
enabled: model.canSeek, bufferedRange: buffered, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
.accessibilityLabel("Playback Position")
.accessibilityValue(time(model.seekPosition))
.accessibilityValue(time(model.seekPosition) + (bufferedStatus.map { ", " + $0 } ?? ""))
.help(bufferedStatus ?? "Playback Position")
Button {
showsRemainingTime.toggle()
model.revealControls()
+40 -2
View File
@@ -4,8 +4,10 @@ extern "C" {
#include <libavformat/avformat.h>
}
#include <array>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cerrno>
#include <condition_variable>
#include <cstdint>
@@ -48,7 +50,8 @@ public:
operationSerial.store(serial.load(std::memory_order_acquire), std::memory_order_relaxed);
deadline.store(now() + 15'000'000'000LL, std::memory_order_relaxed);
}
void start(std::array<int, 2> indices) {
void start(std::array<int, 2> indices, double timelineOrigin) {
origin = timelineOrigin;
for (int slot = 0; slot < 2; ++slot) {
lanes[slot].index = indices[slot];
if (indices[slot] < 0) continue;
@@ -69,6 +72,7 @@ public:
void seek(int streamIndex, int64_t timestamp) {
std::lock_guard lock(mutex);
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = false;
failure = 0;
seekIndex = streamIndex;
@@ -81,11 +85,13 @@ public:
std::lock_guard lock(mutex);
skipAudio = discard;
if (discard) clear(lanes[0]);
if (discard && scratchSlot == 0) scratchSlot = -1;
changed.notify_all();
}
void finish() {
std::lock_guard lock(mutex);
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = true;
seeking = false;
failure = 0;
@@ -100,13 +106,23 @@ public:
std::lock_guard lock(mutex);
return failure;
}
double bufferedEnd(int slot) const {
std::lock_guard lock(mutex);
if (failure || seeking) return 0;
const auto &lane = lanes[slot];
const double queued = lane.count ?
lane.ends[(lane.first + lane.count - 1) % packetLimit] : 0;
return std::max(queued, scratchSlot == slot ? scratchEnd : 0);
}
// 1: packet, 0: not yet available, AVERROR_EOF: this input is drained.
int take(int slot, AVPacket *destination) {
int take(int slot, AVPacket *destination, double &bufferedEnd) {
std::lock_guard lock(mutex);
if (failure) return failure;
bufferedEnd = 0;
auto &lane = lanes[slot];
if (lane.count) {
auto *packet = lane.packets[lane.first];
bufferedEnd = lane.ends[lane.first];
lane.bytes -= packetBytes(packet);
av_packet_move_ref(destination, packet);
lane.first = (lane.first + 1) % packetLimit;
@@ -120,6 +136,7 @@ public:
private:
struct Lane {
std::array<AVPacket *, packetLimit> packets{};
std::array<double, packetLimit> ends{};
int index = -1;
std::size_t first = 0, count = 0, bytes = 0;
};
@@ -137,6 +154,8 @@ private:
bool seeking = false, ended = false, skipAudio = false;
int seekIndex = -1, failure = 0;
int64_t seekTimestamp = 0;
int scratchSlot = -1;
double scratchEnd = 0, origin = 0;
std::thread reader;
static int64_t now() {
@@ -153,6 +172,20 @@ private:
}
return bytes;
}
double packetEnd(int slot, const AVPacket *packet) const {
if (packet->pts == AV_NOPTS_VALUE || (packet->flags & AV_PKT_FLAG_DISCARD)) return 0;
int64_t timestamp = packet->pts;
if (slot == 1) {
// PTS alone can jump to a future reference frame before its B-frames arrive.
// Unknown DTS contributes no encoded-video estimate; decoded frames still count.
if (packet->dts == AV_NOPTS_VALUE) return 0;
timestamp = std::min(timestamp, packet->dts);
}
const double tick = av_q2d(format->streams[lanes[slot].index]->time_base);
const double end = static_cast<double>(timestamp) * tick - origin +
static_cast<double>(std::max<int64_t>(packet->duration, 0)) * tick;
return std::isfinite(end) && end > 0 ? end : 0;
}
static void clear(Lane &lane) {
while (lane.count) {
av_packet_unref(lane.packets[lane.first]);
@@ -233,6 +266,8 @@ private:
playerWake.notify_one();
continue;
}
scratchSlot = slot;
scratchEnd = packetEnd(slot, scratch);
changed.wait(lock, [&] {
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
(slot == 0 && skipAudio) ||
@@ -241,11 +276,14 @@ private:
if (stoppingNow()) return;
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
av_packet_unref(scratch);
scratchSlot = -1;
continue;
}
lane.bytes += bytes;
lane.ends[(lane.first + lane.count) % packetLimit] = scratchEnd;
av_packet_move_ref(lane.packets[(lane.first + lane.count) % packetLimit], scratch);
++lane.count;
scratchSlot = -1;
playerWake.notify_one();
}
} catch (...) {
+56 -3
View File
@@ -61,6 +61,8 @@ struct Track {
AVPacket *packet = nullptr;
bool packetPending = false;
bool receiving = false;
double packetBufferedEnd = 0;
double decoderBufferedEnd = 0;
bool sourceEnded = false;
bool drainSent = false;
bool drained = false;
@@ -247,6 +249,8 @@ struct Player {
if (last.repetitions == std::numeric_limits<uint64_t>::max()) return false;
++last.repetitions;
state.seek_pending = true;
state.buffered_start = state.buffered_end = 0;
++state.revision;
wake.notify_one();
return true;
}
@@ -260,6 +264,11 @@ struct Player {
state.status = PLAYER_OPENING;
state.revision = revision;
}
if (commands.back().action == Action::Seek || commands.back().action == Action::Step ||
commands.back().action == Action::Close) {
state.buffered_start = state.buffered_end = 0;
++state.revision;
}
if (commands.back().action == Action::Seek || commands.back().action == Action::Step)
state.seek_pending = true;
wake.notify_one();
@@ -298,6 +307,41 @@ struct Player {
throw std::runtime_error(std::string(operation) + ": " + details);
}
double retainedTrackEnd(int slot) const {
const auto &track = tracks[slot];
double end = slot == 0 ? std::max(audioWriteEnd, resampleNextPts) : videoEnd;
if (track.packetPending) end = std::max(end, track.packetBufferedEnd);
end = std::max(end, track.decoderBufferedEnd);
if (track.input) end = std::max(end, track.input->bufferedEnd(slot));
return end;
}
void publishBufferedRange() {
state.buffered_start = state.buffered_end = 0;
if (!network || state.seek_pending || frameStepping ||
(status != PLAYER_PAUSED && status != PLAYER_PLAYING) ||
!std::isfinite(duration) || duration <= 0 ||
!std::isfinite(position) || position < 0 || position >= duration) return;
const bool audio = tracks[0].codec != nullptr;
const bool video = tracks[1].codec != nullptr;
if (!audio && !video) return;
const double audioEnd = audio ? retainedTrackEnd(0) : 0;
const double videoEnd = video ? retainedTrackEnd(1) : 0;
double end = audio ? audioEnd : videoEnd;
if (audio && video) {
const bool audioFinished = audioDrained();
const bool videoFinished = tracks[1].drained;
// Exhausted audio is synthesized as silence; exhausted video holds its last frame.
// Neither shorter track caps the other's retained tail, but EOF is not a full cache.
end = audioFinished ? (videoFinished ? std::max(audioEnd, videoEnd) : videoEnd) :
(videoFinished ? audioEnd : std::min(audioEnd, videoEnd));
}
end = std::min(end, duration);
if (!std::isfinite(end) || end <= position) return;
state.buffered_start = position;
state.buffered_end = end;
}
void publish() {
std::lock_guard<std::mutex> lock(mutex);
if (activeEpoch != requestedEpoch.load(std::memory_order_relaxed)) return;
@@ -315,6 +359,7 @@ struct Player {
return (command.action == Action::Seek || command.action == Action::Step) &&
command.epoch == activeEpoch;
});
publishBufferedRange();
state.video_aspect_ratio = 0;
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
const auto &track = tracks[1];
@@ -401,6 +446,7 @@ struct Player {
track.drainSent = track.drained = false;
av_packet_unref(track.packet);
track.packetPending = track.receiving = track.sourceEnded = false;
track.packetBufferedEnd = track.decoderBufferedEnd = 0;
track.countedTimestamp = AV_NOPTS_VALUE;
track.timestampOccurrence = 0;
const AVStream *stream = track.stream();
@@ -562,7 +608,7 @@ struct Player {
std::array<int, 2> indices{-1, -1};
for (int slot = 0; slot < 2; ++slot)
if (tracks[slot].input == input.get()) indices[slot] = tracks[slot].index;
input->start(indices);
input->start(indices, origin);
}
}
status = PLAYER_PAUSED;
@@ -1000,9 +1046,11 @@ struct Player {
const int result = avcodec_receive_frame(track.codec, decoded);
if (result == AVERROR(EAGAIN)) {
track.receiving = false;
track.decoderBufferedEnd = 0;
} else if (result == AVERROR_EOF) {
track.drained = true;
track.receiving = false;
track.decoderBufferedEnd = 0;
} else {
check(result, "Decoding media");
if (slot == 0) convertAudio(); else queueVideo(stepping);
@@ -1015,6 +1063,7 @@ struct Player {
const int result = avcodec_send_packet(track.codec, track.packet);
if (result != AVERROR(EAGAIN)) {
check(result, "Sending media packet to decoder");
track.decoderBufferedEnd = std::max(track.decoderBufferedEnd, track.packetBufferedEnd);
av_packet_unref(track.packet);
track.packetPending = false;
}
@@ -1023,7 +1072,7 @@ struct Player {
return true;
}
if (track.input && !track.sourceEnded) {
const int result = track.input->take(slot, track.packet);
const int result = track.input->take(slot, track.packet, track.packetBufferedEnd);
if (result == AVERROR_EOF) track.sourceEnded = true;
else if (result == 1) track.packetPending = true;
else check(result, "Reading network media packet");
@@ -1304,7 +1353,11 @@ PlayerState player_get_state(Player *player) {
try {
if (player) {
std::lock_guard<std::mutex> lock(player->mutex);
return player->state;
PlayerState result = player->state;
if (result.seek_pending ||
player->publishedEpoch != player->requestedEpoch.load(std::memory_order_relaxed))
result.buffered_start = result.buffered_end = 0;
return result;
}
} catch (const std::exception &exception) {
PlayerState result{};
+4
View File
@@ -15,6 +15,10 @@ typedef struct PlayerState {
PlayerStatus status;
double position;
double duration;
// Retained playable network media ahead, on the same timeline as position.
// Empty/unavailable (including local playback and pending seeks) is 0, 0.
double buffered_start;
double buffered_end;
bool has_audio;
bool has_video;
// False for live/non-seekable inputs; reflects both inputs for separate streams.