From 5087a0a0f00826a4fd37bb0daee5b0aa1559c034 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 02:54:01 +0200 Subject: [PATCH] feat: visualize retained network playback buffer --- Checks/network-playback.mm | 169 ++++++++++++++++++++++++++++----- Checks/streaming.py | 42 +++++++- Checks/system-playback.swift | 7 ++ README.md | 10 +- Sources/App/TerebiApp.swift | 32 ++++++- Sources/Core/network_input.hpp | 42 +++++++- Sources/Core/player.cpp | 59 +++++++++++- Sources/Core/player.h | 4 + 8 files changed, 326 insertions(+), 39 deletions(-) diff --git a/Checks/network-playback.mm b/Checks/network-playback.mm index 10eeaa4..75a0dbe 100644 --- a/Checks/network-playback.mm +++ b/Checks/network-playback.mm @@ -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 &ready, @@ -21,6 +44,7 @@ static PlayerState until(Player *player, const std::function 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 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"); diff --git a/Checks/streaming.py b/Checks/streaming.py index 5f349d2..e42fb35 100755 --- a/Checks/streaming.py +++ b/Checks/streaming.py @@ -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 diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index 77c0783..db1b5de 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -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, diff --git a/README.md b/README.md index f315815..b4a57a5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 9bb72d7..b7ffdda 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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? { + 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 var volume = false var enabled = true + var bufferedRange: ClosedRange? 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? 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() diff --git a/Sources/Core/network_input.hpp b/Sources/Core/network_input.hpp index 98ffeb2..686520c 100644 --- a/Sources/Core/network_input.hpp +++ b/Sources/Core/network_input.hpp @@ -4,8 +4,10 @@ extern "C" { #include } #include +#include #include #include +#include #include #include #include @@ -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 indices) { + void start(std::array 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 packets{}; + std::array 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(timestamp) * tick - origin + + static_cast(std::max(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 (...) { diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index 4e614dc..2ec68e7 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -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::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 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 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 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{}; diff --git a/Sources/Core/player.h b/Sources/Core/player.h index 81ebfd7..685ae88 100644 --- a/Sources/Core/player.h +++ b/Sources/Core/player.h @@ -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.