fix: avoid unbounded HTTP throttling and make buffer readout opt-in
This commit is contained in:
@@ -90,6 +90,7 @@ struct GateState {
|
|||||||
bool entered, released;
|
bool entered, released;
|
||||||
uint64_t waiting, requests, ranges, bytes, total;
|
uint64_t waiting, requests, ranges, bytes, total;
|
||||||
double audioEnd, videoEnd;
|
double audioEnd, videoEnd;
|
||||||
|
uint64_t connections;
|
||||||
};
|
};
|
||||||
|
|
||||||
static GateState gateState(const std::string &base, const std::string &token) {
|
static GateState gateState(const std::string &base, const std::string &token) {
|
||||||
@@ -98,7 +99,8 @@ static GateState gateState(const std::string &base, const std::string &token) {
|
|||||||
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
|
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
|
||||||
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
|
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
|
||||||
[state[@"total"] unsignedLongLongValue],
|
[state[@"total"] unsignedLongLongValue],
|
||||||
[state[@"prefix_ends"][@"audio"] doubleValue], [state[@"prefix_ends"][@"video"] doubleValue]};
|
[state[@"prefix_ends"][@"audio"] doubleValue], [state[@"prefix_ends"][@"video"] doubleValue],
|
||||||
|
[state[@"connections"] unsignedLongLongValue]};
|
||||||
}
|
}
|
||||||
|
|
||||||
static void stillBlocked(const std::string &base, const std::string &token) {
|
static void stillBlocked(const std::string &base, const std::string &token) {
|
||||||
@@ -369,6 +371,43 @@ static void cachedWindow(Player *player, const std::string &base) {
|
|||||||
std::puts("PASS sliding cache: minute read-ahead/thirty-second history/keyframe replay/cached bidirectional seeks/miss refill/cancel");
|
std::puts("PASS sliding cache: minute read-ahead/thirty-second history/keyframe replay/cached bidirectional seeks/miss refill/cancel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void chunkedFetch(Player *player, const std::string &base, bool split) {
|
||||||
|
const std::string videoToken = split ? "chunked-video" : "chunked-combined";
|
||||||
|
const std::string audioToken = "chunked-audio";
|
||||||
|
auto path = [](const std::string &token) {
|
||||||
|
return "/chunked-media/" + token + "/cache.mp4?after_seconds=2";
|
||||||
|
};
|
||||||
|
require(openNetwork(player, base, path(videoToken) + (split ? "" : "&keep_alive=1"),
|
||||||
|
split ? path(audioToken) : "", 120),
|
||||||
|
"open recorded media whose CDN throttles oversized requests");
|
||||||
|
require(player_play(player), "start playback while the minute reserve fills");
|
||||||
|
until(player, [&](auto s) {
|
||||||
|
const auto video = gateState(base, videoToken);
|
||||||
|
const bool throttled = video.entered || (split && gateState(base, audioToken).entered);
|
||||||
|
require(!(s.position > .5 && s.buffering && throttled),
|
||||||
|
"oversized HTTP request starves playback before a minute can buffer");
|
||||||
|
return s.status == PLAYER_PLAYING && buffered(s) && s.buffered_end - s.position >= 59;
|
||||||
|
}, "finite HTTP requests build a minute of playable reserve without CDN throttling");
|
||||||
|
require(gateState(base, videoToken).requests >= 2,
|
||||||
|
"minute read-ahead continues across the first bounded HTTP response");
|
||||||
|
if (split) require(gateState(base, audioToken).requests >= 2,
|
||||||
|
"secondary audio also continues bounded HTTP requests");
|
||||||
|
else require(gateState(base, videoToken).connections == 1,
|
||||||
|
"minute reserve reuses one HTTP connection across bounded responses");
|
||||||
|
require(player_pause(player), "pause before an out-of-cache seek");
|
||||||
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause chunked source");
|
||||||
|
seekCompleted(player, 119.5, PLAYER_PAUSED);
|
||||||
|
require(player_play(player), "resume the final partial HTTP range");
|
||||||
|
until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "bounded final response reaches true media EOF");
|
||||||
|
require(!gateState(base, videoToken).entered &&
|
||||||
|
(!split || !gateState(base, audioToken).entered),
|
||||||
|
"initial fetch, continuation and seek never use CDN-throttled ranges");
|
||||||
|
require(player_close(player), "close chunked source");
|
||||||
|
closed(player);
|
||||||
|
std::puts(split ? "PASS bounded split fetch: minute reserve/secondary audio/range continuation/seek/EOF"
|
||||||
|
: "PASS bounded combined fetch: minute reserve/range continuation/seek/EOF");
|
||||||
|
}
|
||||||
|
|
||||||
static void bufferCoverage(Player *player, const std::string &base) {
|
static void bufferCoverage(Player *player, const std::string &base) {
|
||||||
|
|
||||||
const std::string token = "buffer-release";
|
const std::string token = "buffer-release";
|
||||||
@@ -436,12 +475,12 @@ static void cancellation(Player *player, const std::string &base, const std::str
|
|||||||
}
|
}
|
||||||
seekCompleted(player, 28, (splitAudio || splitVideo) ? PLAYER_PLAYING : PLAYER_PAUSED);
|
seekCompleted(player, 28, (splitAudio || splitVideo) ? PLAYER_PLAYING : PLAYER_PAUSED);
|
||||||
stillBlocked(base, token);
|
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");
|
require(player_play(player), "resume at seek target with stale read still blocked");
|
||||||
until(player, [](auto s) {
|
until(player, [&](auto s) {
|
||||||
return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1 && buffered(s);
|
auto afterSeek = gateState(base, token);
|
||||||
}, "retained coverage follows new stalled-read seek target");
|
return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1 && buffered(s) &&
|
||||||
|
afterSeek.requests > 1 && afterSeek.ranges > 1;
|
||||||
|
}, "new byte-range refill reaches stalled-read seek target");
|
||||||
stillBlocked(base, token);
|
stillBlocked(base, token);
|
||||||
require(player_pause(player), "pause high-timestamp coverage before backward seek");
|
require(player_pause(player), "pause high-timestamp coverage before backward seek");
|
||||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
||||||
@@ -534,6 +573,8 @@ int main(int argc, char **argv) {
|
|||||||
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||||
window.contentView = view;
|
window.contentView = view;
|
||||||
[window orderBack:nil];
|
[window orderBack:nil];
|
||||||
|
chunkedFetch(player.get(), base, false);
|
||||||
|
chunkedFetch(player.get(), base, true);
|
||||||
transport(player.get(), view, base, false);
|
transport(player.get(), view, base, false);
|
||||||
transport(player.get(), view, base, true);
|
transport(player.get(), view, base, true);
|
||||||
frameSteps(player.get(), view, base);
|
frameSteps(player.get(), view, base);
|
||||||
|
|||||||
+24
-11
@@ -24,6 +24,7 @@ class _Gate:
|
|||||||
self.requests = 0
|
self.requests = 0
|
||||||
self.ranges = 0
|
self.ranges = 0
|
||||||
self.bytes = 0
|
self.bytes = 0
|
||||||
|
self.connections = set()
|
||||||
|
|
||||||
|
|
||||||
class StreamingServer:
|
class StreamingServer:
|
||||||
@@ -60,10 +61,14 @@ class StreamingServer:
|
|||||||
pass # Media URLs/headers never appear in logs.
|
pass # Media URLs/headers never appear in logs.
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
try:
|
|
||||||
owner._get(self)
|
owner._get(self)
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
try:
|
||||||
|
super().handle()
|
||||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||||
pass # The engine intentionally cancels active media connections.
|
# Cancellation can close a body read or an idle keep-alive connection.
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
self.http.daemon_threads = True
|
self.http.daemon_threads = True
|
||||||
@@ -163,19 +168,20 @@ class StreamingServer:
|
|||||||
gate = self.gates.get(parts[2])
|
gate = self.gates.get(parts[2])
|
||||||
if gate is None:
|
if gate is None:
|
||||||
body = {"entered": False, "released": False, "waiting": 0, "requests": 0,
|
body = {"entered": False, "released": False, "waiting": 0, "requests": 0,
|
||||||
"ranges": 0, "bytes": 0, "total": 0, "prefix_ends": {}}
|
"ranges": 0, "bytes": 0, "total": 0, "connections": 0, "prefix_ends": {}}
|
||||||
else:
|
else:
|
||||||
if parts[1] == "release":
|
if parts[1] == "release":
|
||||||
gate.release.set()
|
gate.release.set()
|
||||||
body = {"entered": gate.entered.is_set(), "released": gate.release.is_set(),
|
body = {"entered": gate.entered.is_set(), "released": gate.release.is_set(),
|
||||||
"waiting": gate.waiting, "requests": gate.requests, "ranges": gate.ranges,
|
"waiting": gate.waiting, "requests": gate.requests, "ranges": gate.ranges,
|
||||||
"bytes": gate.bytes, "total": gate.total, "prefix_ends": gate.prefix_ends}
|
"bytes": gate.bytes, "total": gate.total, "connections": len(gate.connections),
|
||||||
|
"prefix_ends": gate.prefix_ends}
|
||||||
self._reply(request, 200, json.dumps(body).encode())
|
self._reply(request, 200, json.dumps(body).encode())
|
||||||
return
|
return
|
||||||
|
|
||||||
mode = parts[0]
|
mode = parts[0]
|
||||||
token = None
|
token = None
|
||||||
if len(parts) == 3 and mode in ("stall", "stall-open"):
|
if len(parts) == 3 and mode in ("stall", "stall-open", "chunked-media"):
|
||||||
token, name = parts[1:]
|
token, name = parts[1:]
|
||||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", token):
|
if not re.fullmatch(r"[A-Za-z0-9_-]+", token):
|
||||||
self._reply(request, 400, b"Invalid gate token", "text/plain")
|
self._reply(request, 400, b"Invalid gate token", "text/plain")
|
||||||
@@ -223,21 +229,27 @@ class StreamingServer:
|
|||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
try:
|
try:
|
||||||
gate_at = (self._timestamp_offset(name, float(query["after_seconds"][0]))
|
gate_at = (self._timestamp_offset(name, float(query["after_seconds"][0]))
|
||||||
if mode == "stall" and "after_seconds" in query else
|
if mode in ("stall", "chunked-media") and "after_seconds" in query else
|
||||||
int(query.get("after", ["131072"])[0]) if mode == "stall" else start)
|
int(query.get("after", ["131072"])[0]) if mode in ("stall", "chunked-media") else start)
|
||||||
except (ValueError, OverflowError):
|
except (ValueError, OverflowError):
|
||||||
self._reply(request, 400, b"Invalid gate offset", "text/plain")
|
self._reply(request, 400, b"Invalid gate offset", "text/plain")
|
||||||
return
|
return
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if token not in self.gates:
|
if token not in self.gates:
|
||||||
prefix = self._prefix_ends(name, gate_at) if mode == "stall" else {}
|
prefix = self._prefix_ends(name, gate_at) if mode in ("stall", "chunked-media") else {}
|
||||||
self.gates[token] = _Gate(size, prefix)
|
self.gates[token] = _Gate(size, prefix)
|
||||||
gate = self.gates[token]
|
gate = self.gates[token]
|
||||||
gate.requests += 1
|
gate.requests += 1
|
||||||
|
gate.connections.add(request.client_address)
|
||||||
gate.ranges += int(range_header is not None)
|
gate.ranges += int(range_header is not None)
|
||||||
# Only the original prefix connection is gated: a seek can create a new
|
# Only the original prefix connection is gated: a seek can create a new
|
||||||
# range request while the old read is still blocked and never released.
|
# range request while the old read is still blocked and never released.
|
||||||
should_gate = not gate.claimed and start <= gate_at <= end
|
# Simulate a CDN that throttles oversized/open-ended ranges, while
|
||||||
|
# finite requests of at most 10 MiB run at full available speed.
|
||||||
|
oversized = mode == "chunked-media" and (
|
||||||
|
range_header is None or not range_header.split("-", 1)[1]
|
||||||
|
or int(range_header.split("-", 1)[1]) - start + 1 > 10 * 1024 * 1024)
|
||||||
|
should_gate = (mode != "chunked-media" or oversized) and not gate.claimed and start <= gate_at <= end
|
||||||
if should_gate:
|
if should_gate:
|
||||||
gate.claimed = True
|
gate.claimed = True
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -249,10 +261,11 @@ class StreamingServer:
|
|||||||
request.send_header("Content-Length", str(end - start + 1))
|
request.send_header("Content-Length", str(end - start + 1))
|
||||||
if range_header is not None:
|
if range_header is not None:
|
||||||
request.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
request.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||||
request.send_header("Connection", "close")
|
persistent = mode == "chunked-media" and parse_qs(parsed.query).get("keep_alive") == ["1"]
|
||||||
|
request.send_header("Connection", "keep-alive" if persistent else "close")
|
||||||
request.end_headers()
|
request.end_headers()
|
||||||
request.wfile.flush()
|
request.wfile.flush()
|
||||||
request.close_connection = True
|
request.close_connection = not persistent
|
||||||
cursor = start
|
cursor = start
|
||||||
with path.open("rb") as media:
|
with path.open("rb") as media:
|
||||||
media.seek(start)
|
media.seek(start)
|
||||||
|
|||||||
@@ -22,8 +22,20 @@ struct SystemPlaybackChecks {
|
|||||||
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let initialModel = PlayerModel(defaults: defaults)
|
||||||
|
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
|
||||||
|
initialModel.setShowsBufferInfo(true)
|
||||||
|
require(initialModel.showsBufferInfo && initialModel.controlsVisible,
|
||||||
|
"buffer readout can be enabled before playback and reveals controls")
|
||||||
|
initialModel.shutdown()
|
||||||
|
let restoredModel = PlayerModel(defaults: defaults)
|
||||||
|
require(restoredModel.showsBufferInfo, "enabled buffer readout survives model recreation")
|
||||||
|
restoredModel.setShowsBufferInfo(false)
|
||||||
|
require(!restoredModel.showsBufferInfo, "buffer readout can be explicitly disabled")
|
||||||
|
restoredModel.shutdown()
|
||||||
let model = PlayerModel(defaults: defaults)
|
let model = PlayerModel(defaults: defaults)
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
|
require(!model.showsBufferInfo, "explicitly disabled buffer readout survives model recreation")
|
||||||
model.setVolume(0)
|
model.setVolume(0)
|
||||||
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||||
let center = MPNowPlayingInfoCenter.default()
|
let center = MPNowPlayingInfoCenter.default()
|
||||||
@@ -73,6 +85,11 @@ struct SystemPlaybackChecks {
|
|||||||
retainedSnapshot.buffered_end = min(retainedSnapshot.duration, retainedSnapshot.position + 1)
|
retainedSnapshot.buffered_end = min(retainedSnapshot.duration, retainedSnapshot.position + 1)
|
||||||
model.applyState(retainedSnapshot)
|
model.applyState(retainedSnapshot)
|
||||||
require(model.bufferedRange != nil, "loaded selection exposes retained media")
|
require(model.bufferedRange != nil, "loaded selection exposes retained media")
|
||||||
|
let retainedRange = model.bufferedRange
|
||||||
|
model.setShowsBufferInfo(true)
|
||||||
|
require(model.bufferedRange == retainedRange, "enabling buffer readout preserves retained media")
|
||||||
|
model.setShowsBufferInfo(false)
|
||||||
|
require(model.bufferedRange == retainedRange, "disabling buffer readout preserves retained media")
|
||||||
model.closeFile()
|
model.closeFile()
|
||||||
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
|
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
|
||||||
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
|
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ yt-dlp defaults to the highest available SDR resolution at or below **1080p**, f
|
|||||||
|
|
||||||
Seekable network playback targets **60 seconds ahead and 30 seconds behind** using retained compressed audio/video packets, with a shared **128 MiB** byte budget. Time targets are best-effort: bandwidth, bitrate, packet/keyframe layout, source availability, and byte pressure can shorten the window. Playback starts promptly and read-ahead continues in the background, including while paused. Cached seeks replay from the preceding usable keyframe without a new HTTP request; seeks outside the retained window refill normally. This is an in-memory sliding cache, not a full-video download or persistent disk cache.
|
Seekable network playback targets **60 seconds ahead and 30 seconds behind** using retained compressed audio/video packets, with a shared **128 MiB** byte budget. Time targets are best-effort: bandwidth, bitrate, packet/keyframe layout, source availability, and byte pressure can shorten the window. Playback starts promptly and read-ahead continues in the background, including while paused. Cached seeks replay from the preceding usable keyframe without a new HTTP request; seeks outside the retained window refill normally. This is an in-memory sliding cache, not a full-video download or persistent disk cache.
|
||||||
|
|
||||||
Both player modes display the actual retained times as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead, drawn to scale. Tooltip and VoiceOver provide fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range.
|
Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, continuing sequentially and reusing persistent connections when the server permits. This avoids the open-ended-request throttling seen on some YouTube CDNs; URL extraction alone does not apply yt-dlp's download chunking. Native [`request_size` and `multiple_requests`](https://github.com/FFmpeg/FFmpeg/blob/n9.0.1/doc/protocols.texi#L462-L485) handle continuation without a separate downloader. Large `short_seek_size` read-ahead stays disabled: an uncached seek must escape a stalled response, not wait to drain it. Required headers, fifteen-second I/O deadlines, cache limits, and cancellation remain intact. Live sources keep their existing transport behavior. This removes avoidable request-level throttling, not bandwidth/CDN limits: a minute is a target, not a startup requirement or guaranteed reserve.
|
||||||
|
|
||||||
|
**gear → Show Buffer Info** enables the optional bottom readout in both player modes; it is **disabled by default** and persists across restarts. When enabled, actual retained times appear as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead regardless of this preference; its tooltip and VoiceOver retain fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Hiding the readout does not affect caching or automatic quality. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range.
|
||||||
|
|
||||||
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
|
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.
|
||||||
|
|
||||||
@@ -146,7 +148,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-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent.
|
||||||
|
|
||||||
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`.
|
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`.
|
||||||
|
|
||||||
`make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and opt-in persistence. The quality clock is injected; tests use isolated preferences and muted playback. Both targets are included in `make check`; the native adaptive portion is skipped if yt-dlp is absent. No external network is required.
|
`make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and opt-in persistence. The quality clock is injected; tests use isolated preferences and muted playback. Both targets are included in `make check`; the native adaptive portion is skipped if yt-dlp is absent. No external network is required.
|
||||||
|
|
||||||
@@ -156,7 +158,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-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified.
|
||||||
|
|
||||||
`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
|
`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off buffer readout, persisted opt-in/explicit disable, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
|
||||||
|
|
||||||
`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies that the playing process retains a real registered app PID and bundle identity—the prerequisite for third-party Now Playing discovery. It is included in `make check`; it does not require Vorssaint, show a playback window, or touch real playback history. Cold Finder/CLI opens, warm local/YouTube opens, saved-position restoration, same-window reuse, and Vorssaint's adapter discovery were additionally smoke-checked through the real GUI.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published private(set) var youtubeHeight: Int?
|
@Published private(set) var youtubeHeight: Int?
|
||||||
@Published private(set) var youtubeQuality: Int?
|
@Published private(set) var youtubeQuality: Int?
|
||||||
@Published private(set) var automaticYouTubeQuality = false
|
@Published private(set) var automaticYouTubeQuality = false
|
||||||
|
@Published private(set) var showsBufferInfo = false
|
||||||
@Published var seekPosition = 0.0
|
@Published var seekPosition = 0.0
|
||||||
@Published var isSeeking = false
|
@Published var isSeeking = false
|
||||||
@Published private(set) var volume = 1.0
|
@Published private(set) var volume = 1.0
|
||||||
@@ -65,6 +66,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.qualityClock = qualityClock
|
self.qualityClock = qualityClock
|
||||||
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
|
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
|
||||||
|
showsBufferInfo = defaults.bool(forKey: "showsBufferInfo")
|
||||||
playbackProgress = PlaybackProgress(defaults: defaults)
|
playbackProgress = PlaybackProgress(defaults: defaults)
|
||||||
let player = player_create()
|
let player = player_create()
|
||||||
handle = player
|
handle = player
|
||||||
@@ -158,6 +160,12 @@ final class PlayerModel: ObservableObject {
|
|||||||
resolveYouTube(url, height: nil)
|
resolveYouTube(url, height: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setShowsBufferInfo(_ enabled: Bool) {
|
||||||
|
showsBufferInfo = enabled
|
||||||
|
defaults.set(enabled, forKey: "showsBufferInfo")
|
||||||
|
revealControls()
|
||||||
|
}
|
||||||
|
|
||||||
func setAutomaticYouTubeQuality(_ enabled: Bool) {
|
func setAutomaticYouTubeQuality(_ enabled: Bool) {
|
||||||
automaticYouTubeQuality = enabled
|
automaticYouTubeQuality = enabled
|
||||||
defaults.set(enabled, forKey: "automaticYouTubeQuality")
|
defaults.set(enabled, forKey: "automaticYouTubeQuality")
|
||||||
@@ -822,8 +830,9 @@ private struct PlayerContent: View {
|
|||||||
@State private var controlOffset = CGSize.zero
|
@State private var controlOffset = CGSize.zero
|
||||||
@State private var dragOrigin = CGSize.zero
|
@State private var dragOrigin = CGSize.zero
|
||||||
|
|
||||||
private var controlHeight: CGFloat { model.isNetworkMedia ? 88 : 68 }
|
private var showsBufferReadout: Bool { model.isNetworkMedia && model.showsBufferInfo }
|
||||||
private var compactControlHeight: CGFloat { model.isNetworkMedia ? 52 : 40 }
|
private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
|
||||||
|
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
|
||||||
|
|
||||||
private var bufferedStatus: String? {
|
private var bufferedStatus: String? {
|
||||||
model.bufferedRange.map {
|
model.bufferedRange.map {
|
||||||
@@ -931,6 +940,10 @@ private struct PlayerContent: View {
|
|||||||
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
|
Toggle("Show Buffer Info", isOn: Binding(
|
||||||
|
get: { model.showsBufferInfo },
|
||||||
|
set: { model.setShowsBufferInfo($0) }))
|
||||||
|
Divider()
|
||||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||||
.disabled(!model.canToggleCompact)
|
.disabled(!model.canToggleCompact)
|
||||||
Toggle("Snap to Screen Edges", isOn: Binding(
|
Toggle("Snap to Screen Edges", isOn: Binding(
|
||||||
@@ -959,10 +972,10 @@ private struct PlayerContent: View {
|
|||||||
.disabled(model.compactWindow.isTransitioning)
|
.disabled(model.compactWindow.isTransitioning)
|
||||||
actionsMenu
|
actionsMenu
|
||||||
}
|
}
|
||||||
if model.isNetworkMedia { bufferReadout }
|
if showsBufferReadout { bufferReadout }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, model.isNetworkMedia ? 6 : 8)
|
.padding(.vertical, showsBufferReadout ? 6 : 8)
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
|
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
|
||||||
.onHover(perform: model.setControlsHovered)
|
.onHover(perform: model.setControlsHovered)
|
||||||
@@ -1040,7 +1053,7 @@ private struct PlayerContent: View {
|
|||||||
}
|
}
|
||||||
.font(.system(size: 11).monospacedDigit())
|
.font(.system(size: 11).monospacedDigit())
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
if model.isNetworkMedia { bufferReadout }
|
if showsBufferReadout { bufferReadout }
|
||||||
}
|
}
|
||||||
.padding(.vertical, 11)
|
.padding(.vertical, 11)
|
||||||
// Optical centering accounts for symbol insets and the native seek thumb.
|
// Optical centering accounts for symbol insets and the native seek thumb.
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ struct Player {
|
|||||||
steadyAnchor = Clock::now();
|
steadyAnchor = Clock::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers) {
|
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, bool live) {
|
||||||
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
|
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
|
||||||
auto &input = *inputs[slot];
|
auto &input = *inputs[slot];
|
||||||
input.format = avformat_alloc_context();
|
input.format = avformat_alloc_context();
|
||||||
@@ -494,6 +494,10 @@ struct Player {
|
|||||||
AVDictionary *options = nullptr;
|
AVDictionary *options = nullptr;
|
||||||
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||||
if (result >= 0) result = av_dict_set(&options, "rw_timeout", "15000000", 0);
|
if (result >= 0) result = av_dict_set(&options, "rw_timeout", "15000000", 0);
|
||||||
|
// Some YouTube CDNs throttle open-ended ranges; FFmpeg continues bounded requests.
|
||||||
|
if (result >= 0 && !live) result = av_dict_set(&options, "request_size", "10485760", 0);
|
||||||
|
if (result >= 0 && !live) result = av_dict_set(&options, "multiple_requests", "1", 0);
|
||||||
|
// Leave short-seek read-ahead disabled: cache misses must escape stalled responses.
|
||||||
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
|
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
av_dict_free(&options);
|
av_dict_free(&options);
|
||||||
@@ -520,7 +524,7 @@ struct Player {
|
|||||||
status = PLAYER_OPENING;
|
status = PLAYER_OPENING;
|
||||||
publish();
|
publish();
|
||||||
if (network) {
|
if (network) {
|
||||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders);
|
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live);
|
||||||
format = primary->format;
|
format = primary->format;
|
||||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||||
if (command.audioURL.empty()) {
|
if (command.audioURL.empty()) {
|
||||||
@@ -528,7 +532,7 @@ struct Player {
|
|||||||
} else {
|
} else {
|
||||||
if (!tracks[1].codec)
|
if (!tracks[1].codec)
|
||||||
throw std::runtime_error("The selected video stream contains no playable video");
|
throw std::runtime_error("The selected video stream contains no playable video");
|
||||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders);
|
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live);
|
||||||
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
|
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
|
||||||
if (!tracks[0].codec)
|
if (!tracks[0].codec)
|
||||||
throw std::runtime_error("The selected audio stream contains no playable audio");
|
throw std::runtime_error("The selected audio stream contains no playable audio");
|
||||||
|
|||||||
Reference in New Issue
Block a user