feat: retain streaming media and add opt-in automatic quality

This commit is contained in:
2026-10-11 11:17:41 +02:00
parent 041d2b0644
commit 13554a1574
12 changed files with 921 additions and 164 deletions
+79
View File
@@ -0,0 +1,79 @@
import AppKit
@MainActor
private final class FixedQualityClock { var now = 1000.0 }
@main
@MainActor
struct AutomaticQualityPlaybackChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() {
FileHandle.standardError.write(Data(("FAIL: " + message + "\n").utf8))
exit(1)
}
}
// Only the infrastructure watchdog uses elapsed time. Adaptation uses an injected clock;
// playback readiness and network stalls are observable media events, not sleeps.
static func until(_ model: PlayerModel, _ message: String, _ condition: () -> Bool) {
let deadline = ContinuousClock.now.advanced(by: .seconds(20))
while !condition(), ContinuousClock.now < deadline {
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
}
require(condition(), message + " (height=\(model.youtubeHeight ?? 0), status=\(model.state.status), error=\(model.errorText ?? "none"))")
}
static func polled(_ model: PlayerModel) {
let revision = model.state.revision
until(model, "native model processes a fresh playback snapshot") { model.state.revision != revision }
}
static func main() {
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
let suite = "terebi.automatic-quality-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let clock = FixedQualityClock()
let model = PlayerModel(defaults: defaults, qualityClock: { clock.now })
defer { model.shutdown() }
model.setVolume(0)
require(!model.automaticYouTubeQuality, "automatic quality is opt-in")
model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
until(model, "default selects real 1080p and runs out of the gated network prefix") {
model.youtubeHeight == 1080 && model.canTogglePlayback &&
model.state.status == PLAYER_PLAYING && model.state.position > 0.5 && model.state.buffering
}
clock.now = 2000
polled(model)
require(model.youtubeHeight == 1080 && !model.resolvingYouTube,
"disabled automatic quality leaves a stalled manual/default stream alone")
model.setAutomaticYouTubeQuality(true)
polled(model)
let position = model.state.position
clock.now = 2003
until(model, "persistent stall automatically reopens actual 720p media") {
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
}
require(model.state.position >= position && model.state.position < position + 3,
"automatic quality reopening preserves current playback progress")
polled(model)
clock.now = 2063
until(model, "sustained health automatically restores actual 1080p media") {
model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && !model.state.seek_pending
}
model.setYouTubeQuality(model.youtubeHeight)
require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING,
"reselecting the current manual resolution disables auto without disrupting playback")
model.setAutomaticYouTubeQuality(true)
let reopened = PlayerModel(defaults: defaults, qualityClock: { clock.now })
require(reopened.automaticYouTubeQuality, "explicit opt-in survives player restart")
reopened.shutdown()
model.setYouTubeQuality(nil)
require(!model.automaticYouTubeQuality && model.youtubeQuality == nil,
"default quality is also a manual override")
let manual = PlayerModel(defaults: defaults, qualityClock: { clock.now })
require(!manual.automaticYouTubeQuality, "manual override persists after restart")
manual.shutdown()
model.closeFile()
print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/opt-in persistence")
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Run adaptive PlayerModel playback against real loopback media and offline yt-dlp metadata."""
import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import tempfile
from streaming import StreamingServer
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary", type=Path)
parser.add_argument("fixtures", type=Path)
args = parser.parse_args()
extractor = shutil.which("yt-dlp")
if extractor is None:
extractor = next((str(path) for path in (Path("/opt/homebrew/bin/yt-dlp"), Path("/usr/local/bin/yt-dlp"))
if os.access(path, os.X_OK)), None)
if extractor is None:
print("SKIP native adaptive playback: yt-dlp is not installed; controlled-clock policy checks still run")
return 0
required = ("quality-1080.mp4", "quality-720.mp4")
if any(not (args.fixtures / name).is_file() for name in required):
parser.error("Missing real quality playback fixtures")
with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
root = Path(directory)
formats = []
for height, width in ((720, 1280), (1080, 1920)):
path = "/media/quality-720.mp4" if height == 720 else "/stall/quality-low/quality-1080.mp4?after_seconds=2"
formats.append({"format_id": str(height), "height": height, "width": width,
"url": server.base_url + path, "protocol": "http", "ext": "mp4",
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
"http_headers": {"X-Terebi-Fixture": "combined"}})
info = {"id": "aqz-KE-bpKQ", "title": "Automatic quality playback fixture", "duration": 120,
"is_live": False, "webpage_url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
"extractor": "youtube", "extractor_key": "Youtube", "formats": formats}
metadata = root / "info.json"
metadata.write_text(json.dumps(info))
wrapper = root / "yt-dlp"
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor)
+ " --load-info-json " + shlex.quote(str(metadata))
+ ' "${args[@]:0:${#args[@]}-2}"\n')
wrapper.chmod(0o755)
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""))
try:
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
except subprocess.TimeoutExpired:
raise SystemExit("FAIL: adaptive playback exceeded its infrastructure deadline")
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())
+145
View File
@@ -0,0 +1,145 @@
import Foundation
@main
struct AutomaticQualityChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
static func sample(at now: Double, ahead: Double? = 30, height: Int? = 720) -> AutomaticQuality.Sample {
.init(position: 100 + now, duration: 1000, ahead: ahead, height: height,
heights: [240, 480, 720, 1080, 2160])
}
static func main() {
var policy = AutomaticQuality()
for second in 0..<6 {
require(policy.choose(sample(at: Double(second), ahead: 3), at: Double(second)) == nil,
"brief low reserve does not change quality")
}
require(policy.choose(sample(at: 6, ahead: 3), at: 6) == 480, "persistent low reserve steps down one rung")
policy.resetHealth() // Automatic reopen resets evidence without losing its cooldown.
for second in 7..<36 {
require(policy.choose(sample(at: Double(second), ahead: 3, height: 480), at: Double(second)) == nil,
"automatic reopen preserves the 30-second switch cooldown")
}
require(policy.choose(sample(at: 36, ahead: 3, height: 480), at: 36) == 240,
"continued shortage may downshift after cooldown")
policy = AutomaticQuality()
for second in 0..<60 {
require(policy.choose(sample(at: Double(second), height: 240), at: Double(second)) == nil,
"upshift waits for sustained healthy recorded playback")
}
require(policy.choose(sample(at: 60, height: 240), at: 60) == 480,
"healthy playback increases exactly one rung")
for second in 61..<90 {
require(policy.choose(sample(at: Double(second), ahead: 1, height: 480), at: Double(second)) == nil,
"a sudden shortage cannot immediately reverse an upshift")
}
require(policy.choose(sample(at: 90, ahead: 1, height: 480), at: 90) == 240,
"downshift becomes possible after upshift cooldown")
policy = AutomaticQuality()
for second in 0...5 {
require(policy.choose(sample(at: Double(second), ahead: 3), at: Double(second)) == nil, "low reserve warmup")
}
require(policy.choose(sample(at: 6, ahead: 8), at: 6) == nil, "middle reserve clears low-water evidence")
for second in 7..<13 {
require(policy.choose(sample(at: Double(second), ahead: 3), at: Double(second)) == nil,
"hysteresis requires a fresh continuous shortage")
}
require(policy.choose(sample(at: 13, ahead: 3), at: 13) == 480, "new shortage has its own threshold")
policy = AutomaticQuality()
for second in 0...90 {
require(policy.choose(sample(at: Double(second), ahead: 19, height: 480), at: Double(second)) == nil,
"middle reserve is not healthy enough for an upshift")
}
for suppression in ["disabled", "paused", "seeking", "opening", "local", "missing height", "empty heights", "EOF"] {
policy = AutomaticQuality()
for second in 0...150 {
let now = Double(second)
var input = sample(at: now, ahead: 1)
switch suppression {
case "disabled": input.enabled = false
case "paused": input.playing = false
case "seeking": input.seeking = true
case "opening": input.opening = true
case "local": input.network = false
case "missing height": input.height = nil
case "empty heights": input.heights = []
default: input.duration = input.position + 14
}
require(policy.choose(input, at: now) == nil, suppression + " suppresses quality switching")
}
}
for interruption in ["pause", "seek", "open"] {
policy = AutomaticQuality()
require(policy.choose(sample(at: 0), at: 0) == nil, "start healthy evidence")
var input = sample(at: 59)
input.playing = interruption != "pause"
input.seeking = interruption == "seek"
input.opening = interruption == "open"
require(policy.choose(input, at: 59) == nil, "interrupt healthy evidence")
require(policy.choose(sample(at: 60), at: 60) == nil, "resume starts fresh health")
require(policy.choose(sample(at: 119), at: 119) == nil, "no pre-interruption health is reused")
require(policy.choose(sample(at: 120), at: 120) == 1080, "fresh uninterrupted health can upshift")
}
policy = AutomaticQuality()
var sparse = sample(at: 0, ahead: 1)
sparse.heights = [2160, 1080, 480, 144, 480, 0, -1]
require(policy.choose(sparse, at: 0) == nil, "sparse heights warmup")
sparse.position += 6
require(policy.choose(sparse, at: 6) == 480, "nearest lower available rung handles sparse heights")
policy = AutomaticQuality()
sparse.height = 2160
require(policy.choose(sparse, at: 0) == 1080, "enabling auto caps an existing high-resolution selection")
policy = AutomaticQuality()
for second in 0...150 {
require(policy.choose(sample(at: Double(second), height: 1080), at: Double(second)) == nil,
"automatic upshift never exceeds 1080p")
}
for live in [false, true] {
policy = AutomaticQuality()
for second in 0..<120 {
let now = Double(second)
var input = sample(at: now, ahead: live ? 0 : nil, height: 480)
input.live = live
input.duration = 0
require(policy.choose(input, at: now) == nil, "live/unknown reserve needs 120 seconds of stability")
}
var input = sample(at: 120, ahead: live ? 0 : nil, height: 480)
input.live = live
input.duration = 0
require(policy.choose(input, at: 120) == 720, "live/unknown reserve can cautiously upshift")
policy = AutomaticQuality()
input.buffering = true
for second in 0..<3 {
require(policy.choose(input, at: Double(second)) == nil, "brief live/unknown stall is tolerated")
}
require(policy.choose(input, at: 3) == 240, "persistent live/unknown stall downshifts")
}
policy = AutomaticQuality()
let frozen = sample(at: 0, ahead: nil)
for second in 0..<6 {
require(policy.choose(frozen, at: Double(second)) == nil, "position stall first waits for progress then hysteresis")
}
require(policy.choose(frozen, at: 6) == 480, "lack of playback progress also detects a stall")
policy = AutomaticQuality()
require(policy.choose(sample(at: 0, ahead: 1), at: 0) == nil, "failure scenario warmup")
require(policy.choose(sample(at: 6, ahead: 1), at: 6) == 480, "failure scenario requests a switch")
policy.extractionFailed(at: 7)
policy.resetHealth()
for second in 8..<127 {
require(policy.choose(sample(at: Double(second), ahead: 1), at: Double(second)) == nil,
"extraction failure preserves old height and backs off for 120 seconds")
}
require(policy.choose(sample(at: 127, ahead: 1), at: 127) == 480,
"failure backoff permits a later attempt, not a retry every poll")
print("Automatic quality checks passed")
}
}
+47 -22
View File
@@ -33,8 +33,8 @@ static void validBuffer(PlayerState state) {
require(std::isfinite(state.duration) && state.duration > 0 &&
state.buffered_start >= 0 && buffered(state) && state.buffered_end <= state.duration,
"retained range is nonempty and bounded by finite media duration");
require(std::abs(state.buffered_start - state.position) < .00001,
"retained range follows the authoritative playback position, not a loaded prefix");
require(state.buffered_start <= state.position && state.position <= state.buffered_end,
"the retained interval covers the current playhead");
}
// Only these deadlines use elapsed time: a stuck command/read must fail the check,
@@ -330,20 +330,46 @@ static void prefixBound(PlayerState state, GateState gate, bool splitAudio = fal
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
}
static void bufferCoverage(Player *player, const std::string &base) {
require(openNetwork(player, base, "/media/streaming.mp4", "", 30),
"open long network fixture for bounded retained coverage");
auto initial = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"paused network source exposes actual retained media");
require(initial.buffered_end < initial.duration,
"bounded queues do not report an entire downloaded movie as retained");
require(player_play(player), "play to advance retained network coverage");
until(player, [=](auto s) {
return s.status == PLAYER_PLAYING && s.position > initial.buffered_end && buffered(s) &&
s.buffered_end > initial.buffered_end;
}, "retained horizon advances with real playback and refills");
require(player_close(player), "close advancing buffered source");
static void cachedWindow(Player *player, const std::string &base) {
const std::string token = "cached-window";
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=100", "", 120),
"open a two-minute source with a controlled future network stall");
auto initial = until(player, [](auto s) {
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end >= 59;
}, "paused playback prefetches a minute without waiting to start");
require(initial.buffered_end < 65, "read-ahead is time-bounded rather than a full download");
seekCompleted(player, 50, PLAYER_PAUSED);
auto retained = until(player, [&](auto s) {
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end > 95 &&
gateState(base, token).entered;
}, "cache follows a new playhead until the future network gate");
require(retained.position - retained.buffered_start >= 29,
"a reusable thirty-second rewind window survives forward playback");
require(retained.buffered_start > 15, "old media is evicted instead of caching the whole movie");
const auto before = gateState(base, token);
auto rewind = seekCompleted(player, 20.5, PLAYER_PAUSED);
require(buffered(rewind), "cached rewind preserves the retained interval");
auto after = gateState(base, token);
require(after.requests == before.requests && after.ranges == before.ranges,
"rewinding within history makes no new HTTP request");
stillBlocked(base, token);
seekCompleted(player, 45, PLAYER_PAUSED);
after = gateState(base, token);
require(after.requests == before.requests && after.ranges == before.ranges,
"forward seeking inside retained media makes no new HTTP request");
stillBlocked(base, token);
seekCompleted(player, 5, PLAYER_PAUSED);
until(player, [&](auto s) {
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end > 6 &&
gateState(base, token).requests > before.requests;
}, "seeking before retained history refills missing media over HTTP");
require(player_close(player), "close cache while the original future response remains blocked");
closed(player);
stillBlocked(base, token);
std::puts("PASS sliding cache: minute read-ahead/thirty-second history/keyframe replay/cached bidirectional seeks/miss refill/cancel");
}
static void bufferCoverage(Player *player, const std::string &base) {
const std::string token = "buffer-release";
require(openNetwork(player, base, "/stall/" + token + "/streaming.mp4", "", 30),
@@ -373,7 +399,7 @@ static void bufferCoverage(Player *player, const std::string &base) {
}, "released server gate advances retained coverage beyond withheld timestamps");
require(player_close(player), "close released buffered source");
closed(player);
std::puts("PASS retained coverage: bounded queues/advancing clock/stalled timestamp bounds/B-frame dependencies/gate-release refill");
std::puts("PASS retained coverage: supplied timestamp bounds/B-frame dependencies/gate-release refill");
}
static void blockedPlayback(Player *player, const std::string &base, const std::string &token,
@@ -421,19 +447,17 @@ static void cancellation(Player *player, const std::string &base, const std::str
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
"retain high-timestamp media before invalidating it");
seekCompleted(player, .5, PLAYER_PAUSED);
auto rewound = until(player, [](auto s) {
until(player, [](auto s) {
return s.status == PLAYER_PAUSED && !s.seek_pending && buffered(s);
}, "backward seek rebuilds retained coverage at the new target");
require(rewound.buffered_end < 28,
"backward seek drops the old high-water horizon instead of retaining distant media");
stillBlocked(base, token);
require(player_close(player), "close resumed formerly stalled source");
closed(player);
std::puts(splitAudio
? "PASS stalled secondary audio: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
? "PASS stalled secondary audio: required-track bound/seek acknowledgement/new-target coverage/backward refill"
: splitVideo
? "PASS stalled primary video: required-track bound/seek invalidation/new-target coverage/backward high-water clearing"
: "PASS stalled combined input: bounded prefix/pause/seek invalidation/new-target coverage/backward high-water clearing");
? "PASS stalled primary video: required-track bound/seek acknowledgement/new-target coverage/backward refill"
: "PASS stalled combined input: bounded prefix/pause/seek acknowledgement/new-target coverage/backward refill");
}
blockedOpening(player, base, "opening-close");
@@ -515,6 +539,7 @@ int main(int argc, char **argv) {
frameSteps(player.get(), view, base);
nativeHLS(player.get(), view, base);
liveAndReplacement(player.get(), base, fixtures);
cachedWindow(player.get(), base);
bufferCoverage(player.get(), base);
cancellation(player.get(), base, fixtures);
failures(player.get(), base);
+21 -3
View File
@@ -32,6 +32,9 @@ class StreamingServer:
headers = {
"av.mp4": "combined",
"streaming.mp4": "combined",
"cache.mp4": "combined",
"quality-1080.mp4": "combined",
"quality-720.mp4": "combined",
"frame-step.mp4": "combined",
"video.mp4": "video",
"frame-step-video.mp4": "video",
@@ -134,6 +137,19 @@ class StreamingServer:
self.prefixes[key] = {kind: min(end, first_missing[kind]) for kind, end in ends.items()}
return self.prefixes[key]
def _timestamp_offset(self, name: str, seconds: float) -> int:
probe = subprocess.run(
["ffprobe", "-v", "error", "-show_packets", "-show_format",
"-show_entries", "format=start_time:packet=pts_time,pos", "-of", "json",
str(self.fixtures / name)],
capture_output=True, text=True, check=True, timeout=30,
)
metadata = json.loads(probe.stdout)
origin = float(metadata.get("format", {}).get("start_time", 0))
return min(int(packet["pos"]) for packet in metadata["packets"]
if "pos" in packet and "pts_time" in packet
and float(packet["pts_time"]) - origin >= seconds)
def _get(self, request):
parsed = urlsplit(request.path)
parts = unquote(parsed.path).strip("/").split("/")
@@ -206,8 +222,10 @@ class StreamingServer:
if token is not None:
query = parse_qs(parsed.query)
try:
gate_at = int(query.get("after", ["131072"])[0]) if mode == "stall" else start
except ValueError:
gate_at = (self._timestamp_offset(name, float(query["after_seconds"][0]))
if mode == "stall" and "after_seconds" in query else
int(query.get("after", ["131072"])[0]) if mode == "stall" else start)
except (ValueError, OverflowError):
self._reply(request, 400, b"Invalid gate offset", "text/plain")
return
with self.lock:
@@ -270,7 +288,7 @@ def main():
parser.add_argument("fixtures", type=Path)
args = parser.parse_args()
required = ("av.mp4", "video.mp4", "audio.wav", "frame-step-video.mp4",
"frame-step-audio.m4a", "streaming.mp4", "network-vod.m3u8")
"frame-step-audio.m4a", "streaming.mp4", "cache.mp4", "network-vod.m3u8")
missing = [name for name in required if not (args.fixtures / name).is_file()]
if missing:
parser.error("Missing streaming fixtures: " + ", ".join(missing))
+19 -3
View File
@@ -10,7 +10,7 @@ PREFIX ?= $(HOME)/.local
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/Output.o
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
APP := $(BUILD_DIR)/terebi.app
@@ -18,7 +18,7 @@ PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media check-startup
.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-cli check-runtime check-media check-startup
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist
check-dependencies:
@@ -78,7 +78,7 @@ run: all
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media check-startup
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-cli check-runtime check-media check-startup
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies
@@ -123,6 +123,21 @@ $(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/Y
check-youtube: $(BUILD_DIR)/youtube-resolver-check
$(BUILD_DIR)/youtube-resolver-check
$(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@
check-quality: $(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-adaptive: $(BUILD_DIR)/automatic-quality-playback-check
mkdir -p $(BUILD_DIR)/fixtures
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=blue:size=1920x1080:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-1080.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=green:size=1280x720:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-720.mp4
python3 Checks/automatic-quality.py $(BUILD_DIR)/automatic-quality-playback-check $(BUILD_DIR)/fixtures
$(BUILD_DIR)/network-playback-check: Checks/network-playback.mm $(OBJECTS) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
@@ -130,6 +145,7 @@ check-network: $(BUILD_DIR)/network-playback-check check-fixtures
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -an -c:v copy $(BUILD_DIR)/fixtures/frame-step-video.mp4
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -vn -c:a copy $(BUILD_DIR)/fixtures/frame-step-audio.m4a
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 30 -c:v mpeg4 -q:v 2 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/streaming.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -q:v 2 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/cache.mp4
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/av.mp4 -c:v libx264 -g 24 -keyint_min 24 -sc_threshold 0 -c:a aac -hls_time 1 -hls_playlist_type vod -hls_segment_filename $(BUILD_DIR)/fixtures/network-vod-%d.ts $(BUILD_DIR)/fixtures/network-vod.m3u8
python3 Checks/streaming.py $(BUILD_DIR)/network-playback-check $(BUILD_DIR)/fixtures
+14 -7
View File
@@ -64,10 +64,14 @@ Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a Yo
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
Network playback controls show **Buffered: 1.6s ahead** in both normal and compact mode, so short buffers remain visible even on a multi-hour timeline without opening a tooltip. **Buffered: —** means no estimate is available, including during seeks or for live/unknown-duration media. The normal-mode seek bar also highlights the **buffered range** in lighter gray ahead of the playhead, drawn to scale; hover for the buffered-through timestamp, which VoiceOver also includes. This is a conservative playable range constrained by both required audio and video inputs, including decoded/output buffers—not a percentage downloaded or a persistent cache of the whole video. It resets on seeks, quality/source changes, and close. Local playback displays neither indicator. On long videos the shaded segment can be sub-pixel because the retained buffers are bounded; the readout remains legible whenever the controls are visible.
Seekable network playback targets **60 seconds ahead and 30 seconds behind** using retained compressed audio/video packets, with a shared **128 MiB** byte budget. Time targets are best-effort: bandwidth, bitrate, packet/keyframe layout, source availability, and byte pressure can shorten the window. Playback starts promptly and read-ahead continues in the background, including while paused. Cached seeks replay from the preceding usable keyframe without a new HTTP request; seeks outside the retained window refill normally. This is an in-memory sliding cache, not a full-video download or persistent disk cache.
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.
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**. Explicit opt-in persists across restarts; choosing any manual/default resolution, even the current one, disables it. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency.
## Resume playback
@@ -114,11 +118,12 @@ Only implemented actions have controls; there are no placeholder playlist, subti
## Where to change things
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, and live status. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `buffered_start`/`buffered_end` report a conservative retained playable range in normalized seconds, constrained by both tracks; unavailable/local/pending ranges are `0, 0`, and a nonempty range starts at `position` and ends no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes both selected inputs' old timeline state before resuming.
- `Sources/Core/network_input.hpp`: network-only demux readers with bounded encoded packet queues, constant-time timestamp frontiers (conservative for reordered video), asynchronous coordinated seeks, fifteen-second I/O deadlines, and interruption on seek/replacement/close/destruction. Remote input protocols exclude local-file access through manifests.
- `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 interval in normalized seconds, constrained by required tracks; unavailable/local/pending ranges are `0, 0`, the start may include reusable history before `position`, and the end is no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded decoded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. Seeks stop output and flush decoder/output state; network inputs replay retained packets or seek/refill on cache misses.
- `Sources/Core/network_input.hpp`: network-only demux readers with a shared compressed-byte budget, playback-anchored read-ahead/history, keyframe/audio-preroll seek indices, conservative reordered-video timestamp frontiers, cached replay, asynchronous miss seeks, fifteen-second I/O deadlines, and interruption on miss/replacement/close/destruction. Cache hits leave outstanding fetch-frontier I/O intact. Remote input protocols exclude local-file access through manifests.
- `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely.
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, YouTube quality switching with in-memory position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control.
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, manual/automatic YouTube quality switching with 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/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do.
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source.
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference.
@@ -141,7 +146,9 @@ 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. Retained-range checks cover bounded growth, FFprobe-derived supplied-packet timestamp bounds and B-frame dependencies, both split-track constraints, explicit gate-release refill, forward/backward seek invalidation, shorter-track EOF tails, and clearing on close/error/local replacement. Both network and YouTube checks are included in `make check`.
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. 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-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.
@@ -167,4 +174,4 @@ Forward frame stepping retains the live video decoder cursor instead of seeking
Audio buffering is bounded to one second plus one decoded block (up to four seconds); video is bounded to 64 frames/128 MiB. Extremely interleaved files can drop far-future video frames to avoid starving audio; the ceiling and upgrade path are marked at `queueVideo()`. Device/stream latency is accounted for, but device changes during playback are not handled yet.
Network demux queues are additionally bounded to 64 encoded packets/16 MiB per selected track, including packet side data. Each network reader can hold one pending packet while backpressured. Separate inputs retain one authored timestamp origin and one playback clock; buffering stalls that clock rather than skipping ahead. Network reads/seeks have fifteen-second deadlines; there is no automatic reconnection or signed-URL refresh.
Network inputs retain compressed packets within a shared 128 MiB budget, targeting sixty seconds ahead and thirty seconds behind for seekable media. Eviction can shorten history under byte pressure, and keyframe/audio-preroll requirements affect the usable rewind window. Decoded video/audio buffers remain separately bounded. Each network reader can hold one transient demux packet while backpressured. Separate inputs retain one authored timestamp origin and one playback clock; buffering stalls that clock rather than skipping ahead. Live/non-seekable sources do not promise a rewind window or a minute of future live media. Network reads/seeks have fifteen-second deadlines; there is no automatic reconnection or signed-URL refresh.
+95
View File
@@ -0,0 +1,95 @@
import Foundation
// The caller supplies monotonic seconds, keeping the policy independent of timers and extraction.
struct AutomaticQuality {
struct Sample {
var enabled = true
var network = true
var playing = true
var opening = false
var seeking = false
var live = false
var buffering = false
var position: Double
var duration: Double
var ahead: Double?
var height: Int?
var heights: [Int]
}
private var lowSince: Double?
private var healthySince: Double?
private var stallSince: Double?
private var lastPosition: Double?
private var lastProgress: Double?
private var notBefore = -Double.infinity
mutating func resetHealth() {
lowSince = nil
healthySince = nil
stallSince = nil
lastPosition = nil
lastProgress = nil
}
mutating func extractionFailed(at now: Double) {
resetHealth()
notBefore = max(notBefore, now + 120)
}
mutating func choose(_ sample: Sample, at now: Double) -> Int? {
guard sample.enabled, sample.network, sample.playing, !sample.opening, !sample.seeking,
sample.position.isFinite, let height = sample.height, height > 0 else {
resetHealth()
return nil
}
guard !sample.heights.isEmpty,
sample.live || !sample.duration.isFinite || sample.duration <= 0 ||
sample.duration - sample.position > 15 else {
resetHealth()
return nil
}
if let lastPosition, sample.position < lastPosition - 0.1 {
resetHealth()
}
if lastPosition == nil || sample.position > lastPosition! + 0.01 {
lastPosition = sample.position
lastProgress = now
}
let stalled = sample.buffering || now - (lastProgress ?? now) >= 3
let ahead = sample.ahead.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
// Live packet timestamps are not a reliable reserve estimate.
let knownReserve = !sample.live && ahead != nil
if stalled {
stallSince = stallSince ?? now
} else {
stallSince = nil
}
if knownReserve && ahead! < 4 {
lowSince = lowSince ?? now
} else {
lowSince = nil
}
if !stalled && (!knownReserve || ahead! >= 20) {
healthySince = healthySince ?? now
} else {
healthySince = nil
}
guard now >= notBefore else { return nil }
let downshift = stallSince.map({ now - $0 >= 3 }) == true || lowSince.map({ now - $0 >= 6 }) == true
let upshift = healthySince.map({ now - $0 >= (knownReserve ? 60 : 120) }) == true
guard height > 1080 || downshift || upshift else { return nil }
var target: Int?
for available in sample.heights where available > 0 && available <= 1080 {
if height > 1080 || downshift {
if available < height && available > (target ?? 0) { target = available }
} else if available > height && available < (target ?? Int.max) {
target = available
}
}
guard let target else { return nil }
notBefore = now + 30
resetHealth()
return target
}
}
+127 -31
View File
@@ -16,6 +16,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var youtubeHeights: [Int] = []
@Published private(set) var youtubeHeight: Int?
@Published private(set) var youtubeQuality: Int?
@Published private(set) var automaticYouTubeQuality = false
@Published var seekPosition = 0.0
@Published var isSeeking = false
@Published private(set) var volume = 1.0
@@ -48,6 +49,11 @@ final class PlayerModel: ObservableObject {
private var seekPending = false
private var youtubeTask: Task<Void, Never>?
private var youtubeRequest: UUID?
private var resolvingAutomaticQuality = false
private let defaults: UserDefaults
private let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
private var playbackProgress: PlaybackProgress
private var progressURL: URL?
private var resumePosition: Double?
@@ -55,7 +61,10 @@ final class PlayerModel: ObservableObject {
private var lastSavedPosition: Double?
private var systemPlayback: SystemPlaybackControls?
init(defaults: UserDefaults = .standard) {
init(defaults: UserDefaults = .standard, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.defaults = defaults
self.qualityClock = qualityClock
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
playbackProgress = PlaybackProgress(defaults: defaults)
let player = player_create()
handle = player
@@ -74,7 +83,7 @@ final class PlayerModel: ObservableObject {
systemPlayback = SystemPlaybackControls(model: self)
}
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
var isBusy: Bool { (resolvingYouTube && !resolvingAutomaticQuality) || pendingRevision != nil || state.status == PLAYER_OPENING }
var canTogglePlayback: Bool {
handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
@@ -149,9 +158,20 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: nil)
}
func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled
defaults.set(enabled, forKey: "automaticYouTubeQuality")
automaticQuality = AutomaticQuality()
if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() }
revealControls()
}
func setYouTubeQuality(_ height: Int?) {
// A manual choice is an override, including reselecting the active/default resolution.
setAutomaticYouTubeQuality(false)
guard canChangeYouTubeQuality, let url = progressURL, let handle,
height != youtubeQuality, height == nil || youtubeHeights.contains(height!) else { return }
height == nil || youtubeHeights.contains(height!) else { return }
guard height != youtubeQuality else { return }
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
if target == youtubeHeight {
youtubeQuality = height
@@ -162,10 +182,12 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
}
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil) {
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
automatic: Bool = false) {
guard let handle else { return }
cancelYouTubeResolution()
if !player_pause(handle) {
if !automatic { automaticQuality = AutomaticQuality() }
if !automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
revealControls()
return
@@ -173,6 +195,7 @@ final class PlayerModel: ObservableObject {
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
resolvingAutomaticQuality = automatic
systemPlayback?.update()
commandError = nil
revealControls()
@@ -180,20 +203,36 @@ final class PlayerModel: ObservableObject {
do {
let video = try await YouTubeResolver.resolve(url, height: height)
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
self.youtubeTask = nil
self.youtubeRequest = nil
self.resolvingYouTube = false
self.openResolvedYouTube(video, height: height, restoring: restoring)
if automatic {
let current = player_get_state(handle)
guard self.automaticYouTubeQuality, current.status == PLAYER_PLAYING,
!self.isSeeking, !self.seekPending, !current.seek_pending,
self.pendingRevision == nil,
self.isLiveStream || !current.duration.isFinite || current.duration <= 0 ||
current.duration - current.position > 15 else {
self.cancelYouTubeResolution()
self.automaticQuality.resetHealth()
return
}
let position = !self.isLiveStream && current.can_seek ? current.position : nil
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: (position, true), automatic: true)
} else {
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: restoring)
}
} catch {
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
self.youtubeTask = nil
self.youtubeRequest = nil
self.resolvingYouTube = false
self.commandError = error.localizedDescription
if restoring?.playing == true, let handle = self.handle, !player_play(handle) {
self.commandError = error.localizedDescription + "\nCould not resume the previous playback."
self.cancelYouTubeResolution()
if automatic {
self.automaticQuality.extractionFailed(at: self.qualityNow)
} else {
self.commandError = error.localizedDescription
if restoring?.playing == true, let handle = self.handle, !player_play(handle) {
self.commandError = error.localizedDescription + "\nCould not resume the previous playback."
}
self.revealControls()
}
self.revealControls()
}
}
}
@@ -203,9 +242,11 @@ final class PlayerModel: ObservableObject {
youtubeTask?.cancel()
youtubeTask = nil
resolvingYouTube = false
resolvingAutomaticQuality = false
}
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?) {
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?,
automatic: Bool = false) {
guard let handle else { return }
saveProgress()
let revision = player_get_state(handle).revision
@@ -226,6 +267,10 @@ final class PlayerModel: ObservableObject {
}
}
guard accepted else {
if automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
}
commandError = "Could not queue the YouTube video to open. Please try again."
if restoring?.playing == true, !player_play(handle) {
commandError = "Could not queue the YouTube video or resume the previous playback. Please try again."
@@ -234,15 +279,16 @@ final class PlayerModel: ObservableObject {
return
}
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL,
live: video.isLive, restoring: restoring)
live: video.isLive, restoring: restoring, automatic: automatic)
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
}
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
restoring: (position: Double?, playing: Bool)? = nil) {
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
guard let handle else { return }
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
pendingRevision = revision
fileName = title
fileIcon = icon
@@ -299,6 +345,8 @@ final class PlayerModel: ObservableObject {
@discardableResult
func setPlayback(playing: Bool) -> Bool {
guard let handle, canTogglePlayback else { return false }
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() }
let accepted = playing ? player_play(handle) : player_pause(handle)
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
@@ -306,6 +354,8 @@ final class PlayerModel: ObservableObject {
}
func finishSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
let position = min(max(0, seekPosition), duration)
@@ -319,6 +369,7 @@ final class PlayerModel: ObservableObject {
guard let handle, canClose else { return }
saveProgress()
cancelYouTubeResolution()
automaticQuality = AutomaticQuality()
guard player_close(handle) else {
commandError = "Could not queue closing the file. Please try again."
return
@@ -348,6 +399,8 @@ final class PlayerModel: ObservableObject {
@discardableResult
func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false }
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = player_seek(handle, target)
if accepted {
@@ -452,6 +505,8 @@ final class PlayerModel: ObservableObject {
}
func beginSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = true
revealControls()
}
@@ -543,6 +598,7 @@ final class PlayerModel: ObservableObject {
if statusChanged { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED))
updateAutomaticQuality()
if let saved = resumePosition {
resumePosition = nil
guard let handle, next.status == PLAYER_PAUSED || next.status == PLAYER_PLAYING else { return }
@@ -564,6 +620,19 @@ final class PlayerModel: ObservableObject {
}
}
private func updateAutomaticQuality() {
guard automaticYouTubeQuality else { return }
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample(
enabled: automaticYouTubeQuality, network: isNetworkMedia,
playing: state.status == PLAYER_PLAYING, opening: isBusy || resolvingYouTube || resumePosition != nil,
seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream,
buffering: state.buffering, position: state.position, duration: state.duration,
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
guard let height = automaticQuality.choose(sample, at: qualityNow), let url = progressURL else { return }
resolveYouTube(url, height: height, automatic: true)
}
func shutdown() {
saveProgress()
cancelYouTubeResolution()
@@ -756,15 +825,28 @@ private struct PlayerContent: View {
private var controlHeight: CGFloat { model.isNetworkMedia ? 88 : 68 }
private var compactControlHeight: CGFloat { model.isNetworkMedia ? 52 : 40 }
private var bufferedStatus: String? {
model.bufferedRange.map {
let behind = max(0, model.state.position - $0.lowerBound)
let ahead = max(0, $0.upperBound - model.state.position)
return "Reusable buffer: \(String(format: "%.1f", behind)) seconds behind, " +
"\(String(format: "%.1f", ahead)) seconds ahead; buffered through \(time($0.upperBound))."
}
}
private var bufferReadout: some View {
Text(model.bufferedRange.map {
"Buffered: \(String(format: "%.1f", $0.upperBound - $0.lowerBound))s ahead"
} ?? "Buffered: —")
let behind = max(0, model.state.position - $0.lowerBound)
let ahead = max(0, $0.upperBound - model.state.position)
return (model.isCompact ? "" : "Buffer: ") + String(format: "−%.0fs +%.0fs", behind, ahead)
} ?? (model.isCompact ? "Buffer —" : "Buffer: —"))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.white.opacity(0.75))
.lineLimit(1)
.frame(height: 12)
.help("Conservative playable buffer ahead; — means the estimate is unavailable.")
.help(bufferedStatus ?? "Reusable buffer unavailable.")
.accessibilityLabel("Reusable Buffer")
.accessibilityValue(bufferedStatus ?? "Unavailable")
}
private func time(_ seconds: Double) -> String {
@@ -820,15 +902,32 @@ private struct PlayerContent: View {
Button("Close File", action: model.closeFile).disabled(!model.canClose)
if !model.youtubeHeights.isEmpty {
Divider()
Picker("Quality", selection: Binding(
get: { model.youtubeQuality ?? 0 },
set: { model.setYouTubeQuality($0 == 0 ? nil : $0) })) {
Text("Default (up to 1080p)").tag(0)
Menu("Quality") {
Toggle("Automatic Quality (up to 1080p)", isOn: Binding(
get: { model.automaticYouTubeQuality },
set: { model.setAutomaticYouTubeQuality($0) }))
Divider()
Button { model.setYouTubeQuality(nil) } label: {
HStack {
Text("Default (up to 1080p)")
if !model.automaticYouTubeQuality && model.youtubeQuality == nil {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
ForEach(model.youtubeHeights, id: \.self) { height in
Text("\(height)p").tag(height)
Button { model.setYouTubeQuality(height) } label: {
HStack {
Text("\(height)p")
if !model.automaticYouTubeQuality && model.youtubeQuality == height {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
}
}
.disabled(!model.canChangeYouTubeQuality)
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
Divider()
@@ -874,9 +973,6 @@ private struct PlayerContent: View {
private var playbackControls: some View {
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) {
+299 -93
View File
@@ -11,27 +11,61 @@ extern "C" {
#include <cerrno>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <iterator>
#include <limits>
#include <mutex>
#include <new>
#include <stdexcept>
#include <thread>
namespace playback {
class NetworkInput;
// One budget per player, shared by combined or separate compressed inputs.
class StreamCacheBudget {
public:
static constexpr std::size_t byteLimit = 128 * 1024 * 1024;
explicit StreamCacheBudget(std::size_t limit = byteLimit)
: limit(std::min(limit, byteLimit)) {
if (!this->limit) throw std::invalid_argument("The streaming cache budget must be positive");
}
std::size_t bytes() const {
std::lock_guard lock(mutex);
return used;
}
private:
friend class NetworkInput;
const std::size_t limit;
std::size_t used = 0;
mutable std::mutex mutex;
std::condition_variable changed;
std::array<NetworkInput *, 2> inputs{};
};
// Only network inputs need a reader: a blocked socket must never own the player worker.
// Format metadata is immutable after start(); the reader exclusively owns demux and seek I/O.
class NetworkInput {
public:
static constexpr std::size_t packetLimit = 64;
static constexpr std::size_t byteLimit = 16 * 1024 * 1024;
static constexpr double aheadSeconds = 60, behindSeconds = 30;
AVFormatContext *format = nullptr;
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
uint64_t activeEpoch, std::condition_variable &playerWake)
: stopping(stopping), epoch(epoch), activeEpoch(activeEpoch), playerWake(playerWake) {}
uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget)
: stopping(stopping), epoch(epoch), activeEpoch(activeEpoch), playerWake(playerWake), budget(budget) {
std::lock_guard lock(budget.mutex);
auto free = std::find(budget.inputs.begin(), budget.inputs.end(), nullptr);
if (free == budget.inputs.end()) throw std::invalid_argument("Too many streaming inputs");
*free = this;
}
~NetworkInput() {
stop();
for (auto &lane : lanes)
for (auto *packet : lane.packets) av_packet_free(&packet);
{
std::lock_guard lock(budget.mutex);
for (auto &lane : lanes) clear(lane);
for (auto &input : budget.inputs) if (input == this) input = nullptr;
budget.changed.notify_all();
}
av_packet_free(&scratch);
avformat_close_input(&format);
}
@@ -50,14 +84,25 @@ public:
operationSerial.store(serial.load(std::memory_order_acquire), std::memory_order_relaxed);
deadline.store(now() + 15'000'000'000LL, std::memory_order_relaxed);
}
void start(std::array<int, 2> indices, double timelineOrigin) {
origin = timelineOrigin;
for (int slot = 0; slot < 2; ++slot) {
lanes[slot].index = indices[slot];
if (indices[slot] < 0) continue;
for (auto &packet : lanes[slot].packets) {
packet = av_packet_alloc();
if (!packet) throw std::bad_alloc();
void start(std::array<int, 2> indices, double timelineOrigin, bool allowCachedSeeking) {
{
std::lock_guard lock(budget.mutex);
origin = timelineOrigin;
cacheSeeking = allowCachedSeeking;
started = true;
for (int slot = 0; slot < 2; ++slot) {
auto &lane = lanes[slot];
lane.index = indices[slot];
if (lane.index < 0) continue;
const auto *stream = format->streams[lane.index];
lane.tick = av_q2d(stream->time_base);
lane.mediaStart = stream->start_time == AV_NOPTS_VALUE ? 0 :
stream->start_time * lane.tick - origin;
if (stream->duration != AV_NOPTS_VALUE && stream->duration > 0)
lane.mediaEnd = lane.mediaStart + stream->duration * lane.tick;
if (slot == 0 && stream->codecpar->sample_rate > 0)
lane.preroll = std::max(stream->codecpar->seek_preroll, 0) /
static_cast<double>(stream->codecpar->sample_rate);
}
}
scratch = av_packet_alloc();
@@ -66,109 +111,172 @@ public:
}
void stop() {
cancelled.store(true, std::memory_order_relaxed);
changed.notify_all();
budget.changed.notify_all();
if (reader.joinable()) reader.join();
}
void updatePosition(double seconds) {
if (!std::isfinite(seconds)) return;
std::lock_guard lock(budget.mutex);
position = std::max(0.0, seconds);
for (int slot = 0; slot < 2; ++slot) trim(slot);
budget.changed.notify_all();
}
void seek(int streamIndex, int64_t timestamp) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
const double target = (streamIndex < 0 ? static_cast<double>(timestamp) / AV_TIME_BASE :
static_cast<double>(timestamp) * av_q2d(format->streams[streamIndex]->time_base)) - origin;
std::array<uint64_t, 2> cursors{};
bool cached = cacheSeeking && !seeking && !failure && std::isfinite(target);
for (int slot = 0; slot < 2 && cached; ++slot) {
const auto &lane = lanes[slot];
if (lane.index < 0 || (slot == 0 && skipAudio)) continue;
// A forced finish may have interrupted demux. Only a completely cached short
// track can resume without restoring its I/O position through a real seek.
if (halted && !ended && !(std::isfinite(lane.mediaEnd) &&
lane.frontierEnd + lane.tick >= lane.mediaEnd)) cached = false;
else cached = cachedCursor(slot, target, cursors[slot]);
}
position = std::max(0.0, target);
if (cached) {
for (int slot = 0; slot < 2; ++slot)
if (lanes[slot].index >= 0 && !(slot == 0 && skipAudio)) lanes[slot].cursor = cursors[slot];
// Cache hits never interrupt the outstanding read at the fetch frontier.
halted = false;
budget.changed.notify_all();
playerWake.notify_one();
return;
}
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = false;
audioDropped = false;
ended = halted = false;
failure = 0;
seekIndex = streamIndex;
seekTimestamp = timestamp;
seeking = true;
serial.fetch_add(1, std::memory_order_release);
changed.notify_all();
budget.changed.notify_all();
}
void discardAudio(bool discard) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
skipAudio = discard;
if (discard) clear(lanes[0]);
if (discard && scratchSlot == 0) scratchSlot = -1;
changed.notify_all();
// Stepping must not leave an unread audio lane blocking a combined demuxer.
if (discard) {
lanes[0].cursor = tail(lanes[0]);
trim(0);
}
budget.changed.notify_all();
}
void finish() {
std::lock_guard lock(mutex);
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = true;
std::lock_guard lock(budget.mutex);
for (auto &lane : lanes) lane.cursor = tail(lane);
halted = true;
seeking = false;
failure = 0;
serial.fetch_add(1, std::memory_order_release);
changed.notify_all();
budget.changed.notify_all();
}
bool seekPending() const {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
return seeking;
}
int error() const {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
return failure;
}
double bufferedEnd(int slot) const {
std::lock_guard lock(mutex);
if (failure || seeking) return 0;
double bufferedStart(int slot) const {
std::lock_guard lock(budget.mutex);
if (!cacheSeeking || failure || seeking || (slot == 0 && skipAudio)) return NAN;
const auto &lane = lanes[slot];
const double queued = lane.count ?
lane.ends[(lane.first + lane.count - 1) % packetLimit] : 0;
return std::max(queued, scratchSlot == slot ? scratchEnd : 0);
if (lane.points.empty() || (slot == 0 && audioDropped && !trackEnded(lane))) return NAN;
if (slot == 1 && (lane.packets.empty() || position > lane.packets.back().end + lane.tick)) return NAN;
const double start = lane.points.front().time + lane.preroll;
return std::max(0.0, lane.atBeginning ? std::min(start, lane.mediaStart) : start);
}
// 1: packet, 0: not yet available, AVERROR_EOF: this input is drained.
double bufferedEnd(int slot) const {
std::lock_guard lock(budget.mutex);
if (failure || seeking || (slot == 0 && skipAudio)) return 0;
const auto &lane = lanes[slot];
return lane.packets.empty() ? 0 : lane.packets.back().end;
}
// 1: packet, 0: not yet available, AVERROR_EOF: this lane is drained.
int take(int slot, AVPacket *destination, double &bufferedEnd) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
if (failure) return failure;
bufferedEnd = 0;
auto &lane = lanes[slot];
if (lane.count) {
auto *packet = lane.packets[lane.first];
bufferedEnd = lane.ends[lane.first];
lane.bytes -= packetBytes(packet);
av_packet_move_ref(destination, packet);
lane.first = (lane.first + 1) % packetLimit;
--lane.count;
changed.notify_one();
if (lane.cursor < tail(lane)) {
auto &packet = lane.packets[lane.cursor - lane.first];
const int result = av_packet_ref(destination, &packet.packet);
if (result < 0) return result;
bufferedEnd = packet.end;
++lane.cursor;
trim(slot);
budget.changed.notify_all();
return 1;
}
return ended ? AVERROR_EOF : 0;
return ended || halted || trackEnded(lane) ? AVERROR_EOF : 0;
}
private:
struct Packet {
AVPacket packet{};
double end = 0;
std::size_t bytes = 0;
~Packet() { av_packet_unref(&packet); }
Packet() = default;
Packet(const Packet &) = delete;
Packet &operator=(const Packet &) = delete;
};
struct SeekPoint { double time; uint64_t sequence; };
struct Lane {
std::array<AVPacket *, packetLimit> packets{};
std::array<double, packetLimit> ends{};
std::deque<Packet> packets;
std::deque<SeekPoint> points;
int index = -1;
std::size_t first = 0, count = 0, bytes = 0;
uint64_t first = 0, cursor = 0;
std::size_t bytes = 0;
double tick = 0, frontierEnd = 0, mediaStart = 0, mediaEnd = NAN, preroll = 0;
bool atBeginning = false;
};
const std::atomic<bool> &stopping;
const std::atomic<uint64_t> &epoch;
const uint64_t activeEpoch;
std::condition_variable &playerWake;
mutable std::mutex mutex;
std::condition_variable changed;
StreamCacheBudget &budget;
std::array<Lane, 2> lanes;
AVPacket *scratch = nullptr;
std::atomic<bool> cancelled{false};
std::atomic<uint64_t> serial{0}, operationSerial{0};
std::atomic<int64_t> deadline{std::numeric_limits<int64_t>::max()};
bool seeking = false, ended = false, skipAudio = false;
bool seeking = false, ended = false, halted = false, skipAudio = false;
bool cacheSeeking = false, started = false, audioDropped = false;
int seekIndex = -1, failure = 0;
int64_t seekTimestamp = 0;
int scratchSlot = -1;
double scratchEnd = 0, origin = 0;
double origin = 0, position = 0;
std::thread reader;
static int64_t now() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
static std::size_t packetBytes(const AVPacket *packet) {
if (packet->size < 0) return byteLimit + 1;
std::size_t bytes = static_cast<std::size_t>(packet->size);
if (bytes > byteLimit) return byteLimit + 1;
static uint64_t tail(const Lane &lane) { return lane.first + lane.packets.size(); }
static bool trackEnded(const Lane &lane) {
return std::isfinite(lane.mediaEnd) && lane.frontierEnd + lane.tick * 0.5 >= lane.mediaEnd;
}
static double packetTime(const Lane &lane, const AVPacket *packet) {
const int64_t timestamp = packet->pts != AV_NOPTS_VALUE ? packet->pts : packet->dts;
return timestamp == AV_NOPTS_VALUE ? NAN : static_cast<double>(timestamp) * lane.tick;
}
std::size_t packetBytes(const AVPacket *packet) const {
if (packet->size < 0) return budget.limit + 1;
std::size_t bytes = sizeof(Packet) + sizeof(SeekPoint);
const auto payload = packet->buf ? packet->buf->size : static_cast<std::size_t>(packet->size);
if (payload > budget.limit || bytes > budget.limit - payload) return budget.limit + 1;
bytes += payload;
for (int i = 0; i < packet->side_data_elems; ++i) {
if (packet->side_data[i].size > byteLimit - bytes) return byteLimit + 1;
bytes += packet->side_data[i].size;
const auto size = packet->side_data[i].size;
if (size > budget.limit || sizeof(AVPacketSideData) > budget.limit - size ||
bytes > budget.limit - size - sizeof(AVPacketSideData)) return budget.limit + 1;
bytes += size + sizeof(AVPacketSideData);
}
return bytes;
}
@@ -176,23 +284,102 @@ private:
if (packet->pts == AV_NOPTS_VALUE || (packet->flags & AV_PKT_FLAG_DISCARD)) return 0;
int64_t timestamp = packet->pts;
if (slot == 1) {
// PTS alone can jump to a future reference frame before its B-frames arrive.
// Unknown DTS contributes no encoded-video estimate; decoded frames still count.
// PTS can jump to a future reference frame before its B-frames arrive.
if (packet->dts == AV_NOPTS_VALUE) return 0;
timestamp = std::min(timestamp, packet->dts);
}
const double tick = av_q2d(format->streams[lanes[slot].index]->time_base);
const double tick = lanes[slot].tick;
const double end = static_cast<double>(timestamp) * tick - origin +
static_cast<double>(std::max<int64_t>(packet->duration, 0)) * tick;
return std::isfinite(end) && end > 0 ? end : 0;
}
static void clear(Lane &lane) {
while (lane.count) {
av_packet_unref(lane.packets[lane.first]);
lane.first = (lane.first + 1) % packetLimit;
--lane.count;
void pop(Lane &lane) {
const auto bytes = lane.packets.front().bytes;
budget.used -= bytes;
lane.bytes -= bytes;
lane.packets.pop_front();
++lane.first;
lane.atBeginning = false;
while (!lane.points.empty() && lane.points.front().sequence < lane.first) lane.points.pop_front();
}
void clear(Lane &lane) {
budget.used -= lane.bytes;
lane.packets.clear();
lane.points.clear();
lane.first = lane.cursor = 0;
lane.bytes = 0;
lane.frontierEnd = 0;
lane.atBeginning = false;
}
static auto pointAt(const Lane &lane, double seconds) {
return std::upper_bound(lane.points.begin(), lane.points.end(), seconds,
[](double value, const SeekPoint &point) { return value < point.time; });
}
bool cachedCursor(int slot, double target, uint64_t &cursor) const {
const auto &lane = lanes[slot];
if (slot == 0 && std::isfinite(lane.mediaEnd) && target + lane.tick >= lane.mediaEnd) {
cursor = tail(lane);
return true;
}
lane.first = lane.bytes = 0;
if (slot == 0 && audioDropped && !trackEnded(lane)) return false;
if (lane.points.empty() || lane.packets.empty() ||
target > lane.packets.back().end + lane.tick) return false;
const auto after = pointAt(lane, target - lane.preroll);
if (after == lane.points.begin()) {
if (!lane.atBeginning) return false;
cursor = lane.points.front().sequence;
} else cursor = std::prev(after)->sequence;
return true;
}
void trim(int slot) {
auto &lane = lanes[slot];
uint64_t keep = lane.cursor;
if (cacheSeeking) {
const auto after = pointAt(lane, position - behindSeconds - lane.preroll);
if (after == lane.points.begin()) return;
keep = std::min(keep, std::prev(after)->sequence);
}
while (!lane.packets.empty() && lane.first < keep) pop(lane);
}
std::size_t laneLimit() const {
unsigned tracks = 0;
for (const auto *input : budget.inputs) {
if (!input) continue;
if (!input->started) { ++tracks; continue; }
for (int slot = 0; slot < 2; ++slot)
if (input->lanes[slot].index >= 0 && !(slot == 0 && input->skipAudio)) ++tracks;
}
// Each required track gets a reserved eighth; spare capacity remains borrowable.
return tracks > 1 ? budget.limit - budget.limit / 8 : budget.limit;
}
bool makeRoom(Lane &lane, std::size_t bytes) {
const auto quota = std::max(laneLimit(), bytes);
while (budget.used > budget.limit - bytes || lane.bytes > quota - bytes) {
Lane *oldest = nullptr;
double age = -std::numeric_limits<double>::infinity();
for (auto *input : budget.inputs) {
if (!input) continue;
for (auto &candidate : input->lanes) {
if (candidate.packets.empty() || candidate.first >= candidate.cursor) continue;
if (lane.bytes > quota - bytes && &candidate != &lane) continue;
const double distance = input->position - candidate.packets.front().end;
if (!oldest || distance > age) { oldest = &candidate; age = distance; }
}
}
if (!oldest) return false;
pop(*oldest);
}
return true;
}
bool needsRead() const {
for (int slot = 0; slot < 2; ++slot) {
const auto &lane = lanes[slot];
if (lane.index < 0 || (slot == 0 && skipAudio)) continue;
const double goal = std::isfinite(lane.mediaEnd) ?
std::min(position + aheadSeconds, lane.mediaEnd) : position + aheadSeconds;
if (lane.frontierEnd + lane.tick < goal) return true;
}
return false;
}
bool stoppingNow() const {
return cancelled.load(std::memory_order_relaxed) ||
@@ -206,12 +393,12 @@ private:
int64_t timestamp;
bool doSeek;
{
std::unique_lock lock(mutex);
changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
return stoppingNow() || seeking || !ended;
std::unique_lock lock(budget.mutex);
budget.changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
return stoppingNow() || seeking || (!ended && !halted && needsRead());
});
if (stoppingNow()) return;
if (ended && !seeking) continue;
if (!seeking && (ended || halted || !needsRead())) continue;
current = serial.load(std::memory_order_acquire);
doSeek = seeking;
index = seekIndex;
@@ -222,13 +409,14 @@ private:
if (doSeek) {
const int result = index >= 0 ?
av_seek_frame(format, index, timestamp, AVSEEK_FLAG_BACKWARD) :
avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(), timestamp, timestamp, 0);
avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(),
timestamp, timestamp, AVSEEK_FLAG_BACKWARD);
if (result >= 0 && format->pb) {
// An interrupted read leaves AVIO's error sticky even after a successful format seek.
// Interrupted reads leave AVIO's error sticky after a successful seek.
format->pb->error = 0;
format->pb->eof_reached = 0;
}
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
if (serial.load(std::memory_order_acquire) != current) continue;
seeking = false;
if (result < 0) { failure = result; ended = true; }
@@ -236,14 +424,14 @@ private:
if (result < 0) continue;
}
const int result = av_read_frame(format, scratch);
std::unique_lock lock(mutex);
std::unique_lock lock(budget.mutex);
if (stoppingNow()) return;
if (serial.load(std::memory_order_acquire) != current) {
av_packet_unref(scratch);
continue;
}
if (result == AVERROR(EAGAIN)) {
changed.wait_for(lock, std::chrono::milliseconds(5));
budget.changed.wait_for(lock, std::chrono::milliseconds(5));
continue;
}
if (result < 0) {
@@ -256,38 +444,56 @@ private:
int slot = -1;
for (int i = 0; i < 2; ++i)
if (lanes[i].index >= 0 && scratch->stream_index == lanes[i].index) slot = i;
if (slot < 0 || (slot == 0 && skipAudio)) { av_packet_unref(scratch); continue; }
if (slot < 0 || (slot == 0 && skipAudio)) {
if (slot == 0) audioDropped = true;
av_packet_unref(scratch);
continue;
}
auto &lane = lanes[slot];
const auto bytes = packetBytes(scratch);
if (bytes > byteLimit) {
failure = AVERROR(ENOBUFS);
if (bytes > budget.limit) {
failure = scratch->size < 0 ? AVERROR_INVALIDDATA : AVERROR(ENOBUFS);
ended = true;
av_packet_unref(scratch);
playerWake.notify_one();
continue;
}
scratchSlot = slot;
scratchEnd = packetEnd(slot, scratch);
changed.wait(lock, [&] {
// The demuxer's single in-flight packet is not retained until it fits.
// Byte pressure shortens consumed history, never drops unread packets.
budget.changed.wait(lock, [&] {
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
(slot == 0 && skipAudio) ||
(lane.count < packetLimit && lane.bytes + bytes <= byteLimit);
(slot == 0 && skipAudio) || makeRoom(lane, bytes);
});
if (stoppingNow()) return;
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
if (slot == 0 && skipAudio) audioDropped = true;
av_packet_unref(scratch);
scratchSlot = -1;
continue;
}
const double time = packetTime(lane, scratch) - origin;
const double end = std::max(lane.frontierEnd, packetEnd(slot, scratch));
const uint64_t sequence = tail(lane);
lane.packets.emplace_back();
auto &packet = lane.packets.back();
packet.bytes = bytes;
packet.end = end;
av_packet_move_ref(&packet.packet, scratch);
lane.bytes += bytes;
lane.ends[(lane.first + lane.count) % packetLimit] = scratchEnd;
av_packet_move_ref(lane.packets[(lane.first + lane.count) % packetLimit], scratch);
++lane.count;
scratchSlot = -1;
budget.used += bytes;
lane.frontierEnd = end;
const bool seekPoint = !(packet.packet.flags & AV_PKT_FLAG_DISCARD) &&
(slot == 0 || (packet.packet.flags & AV_PKT_FLAG_KEY));
if (seekPoint && std::isfinite(time) &&
(lane.points.empty() || time >= lane.points.back().time)) {
lane.points.push_back({time, sequence});
if (lane.points.size() == 1 && sequence == lane.first &&
time <= lane.mediaStart + lane.tick) lane.atBeginning = true;
}
trim(slot);
playerWake.notify_one();
}
} catch (...) {
std::lock_guard lock(mutex);
std::lock_guard lock(budget.mutex);
failure = AVERROR(ENOMEM);
ended = true;
playerWake.notify_one();
+15 -4
View File
@@ -182,6 +182,7 @@ struct Player {
// Decoders, timeline and output are worker-owned; network readers exclusively own demux I/O.
AVFormatContext *format = nullptr;
playback::StreamCacheBudget cacheBudget;
std::array<std::unique_ptr<playback::NetworkInput>, 2> inputs;
bool network = false;
bool canSeek = false;
@@ -337,8 +338,17 @@ struct Player {
(videoFinished ? audioEnd : std::min(audioEnd, videoEnd));
}
end = std::min(end, duration);
if (!std::isfinite(end) || end <= position) return;
state.buffered_start = position;
if (!std::isfinite(end) || end < position) return;
double start = 0;
for (int slot = 0; slot < 2; ++slot) {
const auto &track = tracks[slot];
if (!track.codec) continue;
double cached = track.input ? track.input->bufferedStart(slot) : NAN;
if (!std::isfinite(cached) && slot == 0 && audioDrained()) cached = this->audioEnd();
start = std::max(start, std::isfinite(cached) ? std::clamp(cached, 0.0, position) : position);
}
if (end <= start) return;
state.buffered_start = start;
state.buffered_end = end;
}
@@ -359,6 +369,7 @@ struct Player {
return (command.action == Action::Seek || command.action == Action::Step) &&
command.epoch == activeEpoch;
});
for (auto &input : inputs) if (input) input->updatePosition(position);
publishBufferedRange();
state.video_aspect_ratio = 0;
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
@@ -475,7 +486,7 @@ struct Player {
}
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers) {
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake);
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
auto &input = *inputs[slot];
input.format = avformat_alloc_context();
if (!input.format) throw std::bad_alloc();
@@ -608,7 +619,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, origin);
input->start(indices, origin, canSeek);
}
}
status = PLAYER_PAUSED;
+2 -1
View File
@@ -15,7 +15,8 @@ typedef struct PlayerState {
PlayerStatus status;
double position;
double duration;
// Retained playable network media ahead, on the same timeline as position.
// Contiguous retained/playable network range on the position timeline.
// The start may include reusable rewind history; the end includes read-ahead.
// Empty/unavailable (including local playback and pending seeks) is 0, 0.
double buffered_start;
double buffered_end;