feat: stream YouTube video with coordinated audio and video inputs
Resolve public YouTube URLs through external yt-dlp and play selected streams in the existing native window. Independent bounded network readers keep one authored timeline. Successful seeks clear FFmpeg's sticky aborted-read AVIO error; manifest seekability uses native format flags rather than a top-level I/O handle. Verified real YouTube playback, seeking, frame stepping, compact mode, cancellation, and the complete native regression checks.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
#include "../Sources/Core/player_macos.h"
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <MetalKit/MetalKit.h>
|
||||
#import <dispatch/dispatch.h>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||
}
|
||||
|
||||
// Only these deadlines use elapsed time: a stuck command/read must fail the check,
|
||||
// not hang CI. Playback assertions use fixture presentation timestamps and events.
|
||||
static PlayerState until(Player *player, const std::function<bool(PlayerState)> &ready,
|
||||
const char *scenario, bool expectError = false) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto state = player_get_state(player);
|
||||
if (state.status == PLAYER_ERROR && !expectError) {
|
||||
std::fprintf(stderr, "FAIL %s: %s\n", scenario, state.error);
|
||||
std::exit(1);
|
||||
}
|
||||
if (ready(state)) return state;
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
|
||||
}
|
||||
auto state = player_get_state(player);
|
||||
std::fprintf(stderr, "FAIL %s: command deadline (status=%d position=%.4f duration=%.4f pending=%d buffering=%d)\n",
|
||||
scenario, state.status, state.position, state.duration, state.seek_pending, state.buffering);
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
static NSDictionary *control(const std::string &base, const std::string &path) {
|
||||
NSString *address = [NSString stringWithUTF8String:(base + path).c_str()];
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:address]];
|
||||
request.timeoutInterval = 5;
|
||||
request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||
dispatch_semaphore_t done = dispatch_semaphore_create(0);
|
||||
__block NSData *body = nil;
|
||||
__block NSHTTPURLResponse *response = nil;
|
||||
__block NSError *failure = nil;
|
||||
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
|
||||
completionHandler:^(NSData *data, NSURLResponse *result, NSError *error) {
|
||||
body = data;
|
||||
response = (NSHTTPURLResponse *)result;
|
||||
failure = error;
|
||||
dispatch_semaphore_signal(done);
|
||||
}];
|
||||
[task resume];
|
||||
if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 6 * NSEC_PER_SEC)) != 0) {
|
||||
[task cancel];
|
||||
require(false, "loopback control request deadline");
|
||||
}
|
||||
require(failure == nil && response.statusCode == 200 && body != nil, "loopback control endpoint responds");
|
||||
id json = [NSJSONSerialization JSONObjectWithData:body options:0 error:nullptr];
|
||||
require([json isKindOfClass:[NSDictionary class]], "loopback control response is a dictionary");
|
||||
return (NSDictionary *)json;
|
||||
}
|
||||
|
||||
struct GateState {
|
||||
bool entered, released;
|
||||
uint64_t waiting, requests, ranges, bytes, total;
|
||||
};
|
||||
|
||||
static GateState gateState(const std::string &base, const std::string &token) {
|
||||
NSDictionary *state = control(base, "/control/status/" + token);
|
||||
return {[state[@"entered"] boolValue], [state[@"released"] boolValue],
|
||||
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
|
||||
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
|
||||
[state[@"total"] unsignedLongLongValue]};
|
||||
}
|
||||
|
||||
static void stillBlocked(const std::string &base, const std::string &token) {
|
||||
auto gate = gateState(base, token);
|
||||
require(gate.entered && !gate.released && gate.waiting > 0,
|
||||
"command completed while original server read remained blocked");
|
||||
}
|
||||
|
||||
static std::string headersFor(const std::string &path) {
|
||||
const char *kind = path.find("audio.wav") != std::string::npos ||
|
||||
path.find("frame-step-audio.m4a") != std::string::npos ? "audio" :
|
||||
path.find("video.mp4") != std::string::npos ? "video" : "combined";
|
||||
return std::string("X-Terebi-Fixture: ") + kind + "\r\n";
|
||||
}
|
||||
|
||||
static bool openNetwork(Player *player, const std::string &base, const std::string &videoPath,
|
||||
const std::string &audioPath = {}, double duration = 4, bool live = false,
|
||||
bool includeHeaders = true) {
|
||||
// Locals intentionally die immediately after the call: the asynchronous engine
|
||||
// must own both selected URLs and both selected HTTP-header blocks.
|
||||
auto video = base + videoPath;
|
||||
auto audio = audioPath.empty() ? std::string{} : base + audioPath;
|
||||
auto videoHeaders = includeHeaders ? headersFor(videoPath) : std::string{};
|
||||
auto audioHeaders = includeHeaders ? headersFor(audioPath) : std::string{};
|
||||
PlayerNetworkSource source{video.c_str(), audio.empty() ? nullptr : audio.c_str(),
|
||||
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live};
|
||||
return player_open_network(player, &source);
|
||||
}
|
||||
|
||||
static void closed(Player *player) {
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source");
|
||||
require(state.position == 0 && state.duration == 0 && !state.has_audio && !state.has_video &&
|
||||
!state.can_seek && !state.buffering && !state.seek_pending && !state.error[0],
|
||||
"close clears all network/media/seek/error state");
|
||||
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network close");
|
||||
}
|
||||
|
||||
static PlayerState seekCompleted(Player *player, double target, PlayerStatus status) {
|
||||
auto revision = player_get_state(player).revision;
|
||||
require(player_seek(player, target), "network seek accepted");
|
||||
auto pending = player_get_state(player);
|
||||
require(pending.seek_pending || std::abs(pending.position - target) < .03,
|
||||
"network seek remains pending until target is authoritative");
|
||||
return until(player, [=](auto s) {
|
||||
return !s.seek_pending && s.revision != revision && s.status == status &&
|
||||
std::abs(s.position - target) < (status == PLAYER_PLAYING ? .2 : .03);
|
||||
}, "network seek acknowledgement");
|
||||
}
|
||||
|
||||
static void transport(Player *player, MTKView *view, const std::string &base, bool split) {
|
||||
require(openNetwork(player, base, split ? "/media/video.mp4" : "/media/av.mp4",
|
||||
split ? "/media/audio.wav" : ""), "network open accepted");
|
||||
require(player_play(player), "play queues during asynchronous network open");
|
||||
auto playing = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .15; },
|
||||
"queued network autoplay");
|
||||
require(playing.has_audio && playing.has_video && playing.can_seek && !playing.seek_pending,
|
||||
"combined and split inputs select both tracks and expose seekability");
|
||||
require(playing.duration >= 3.9 && playing.duration <= 4.2, "network media duration preserves source timing");
|
||||
require(std::abs(playing.video_aspect_ratio - 16.0 / 9) < .0001, "network video keeps display aspect");
|
||||
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network open");
|
||||
[view draw];
|
||||
require(player_pause(player), "pause streaming playback");
|
||||
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "network pause");
|
||||
// An acknowledged external request is an event barrier, not a sleep pretending
|
||||
// the audio callback or command queue must have completed by now.
|
||||
control(base, "/control/stats");
|
||||
require(std::abs(player_get_state(player).position - paused.position) < .0001,
|
||||
"paused streaming audio clock does not advance");
|
||||
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
||||
[view draw];
|
||||
require(player_seek(player, .8) && player_seek(player, 1.7), "queued seeks accepted on both inputs");
|
||||
auto pending = player_get_state(player);
|
||||
require(pending.seek_pending || std::abs(pending.position - 1.7) < .03,
|
||||
"earlier seek cannot acknowledge final split-input seek");
|
||||
until(player, [](auto s) {
|
||||
return !s.seek_pending && s.status == PLAYER_PAUSED && std::abs(s.position - 1.7) < .03;
|
||||
}, "queued final network seek");
|
||||
require(player_play(player), "resume both network inputs");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.8; }, "resume after split seek");
|
||||
seekCompleted(player, 3.2, PLAYER_PLAYING);
|
||||
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "drain streaming media");
|
||||
require(ended.position >= 3.9 && ended.position < 4.2, "network EOF drains decoded audio and video");
|
||||
require(player_play(player), "network replay accepted");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1 && s.position < 1; },
|
||||
"network replay restarts both streams");
|
||||
require(player_close(player), "close playing network source");
|
||||
closed(player);
|
||||
std::puts(split ? "PASS split stream-index-0 inputs: headers/autoplay/duration/pause/paused+playing seek/acknowledgement/EOF/replay"
|
||||
: "PASS combined network: headers/autoplay/duration/pause/paused+playing seek/acknowledgement/EOF/replay");
|
||||
}
|
||||
|
||||
static PlayerState step(Player *player, int direction, double pts) {
|
||||
auto revision = player_get_state(player).revision;
|
||||
require(player_step_frame(player, direction), "split-source adjacent frame step accepted");
|
||||
auto state = until(player, [revision](auto s) {
|
||||
return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED;
|
||||
}, "split frame-step acknowledgement");
|
||||
if (std::abs(state.position - pts) >= .0001) {
|
||||
std::fprintf(stderr, "FAIL split frame PTS: expected %.4f, got %.4f\n", pts, state.position);
|
||||
std::exit(1);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
static void frameSteps(Player *player, MTKView *view, const std::string &base) {
|
||||
constexpr double pts[] = {0, .1, .2, .3, .4, .65, .9, 1.15, 1.4, 1.5, 1.6, 1.7};
|
||||
require(openNetwork(player, base, "/media/frame-step-video.mp4", "/media/audio.wav"),
|
||||
"video-only VFR plus independent audio open");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED && s.has_audio && s.has_video; }, "split VFR open");
|
||||
require(!player_step_frame(player, 0) && !player_step_frame(player, 2), "invalid step directions rejected");
|
||||
step(player, -1, 0);
|
||||
for (unsigned i = 1; i < sizeof(pts) / sizeof(pts[0]); ++i) {
|
||||
step(player, 1, pts[i]);
|
||||
[view draw];
|
||||
}
|
||||
step(player, 1, 1.7);
|
||||
for (int i = 10; i >= 0; --i) step(player, -1, pts[i]);
|
||||
step(player, -1, 0);
|
||||
seekCompleted(player, .72, PLAYER_PAUSED);
|
||||
step(player, 1, 1.15); // The .72 seek previews .9, never the preceding keyframe.
|
||||
step(player, -1, .9);
|
||||
step(player, -1, .65);
|
||||
require(player_play(player), "resume frame-stepped split audio/video");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.3; },
|
||||
"audio rebuild resumes authoritative stepped timestamp");
|
||||
require(player_pause(player), "pause resumed split VFR timeline");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause split VFR after resume");
|
||||
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
||||
require(player_play(player), "play past independent video EOF");
|
||||
auto beyondVideo = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.2; },
|
||||
"audio continues after independent video EOF");
|
||||
require(beyondVideo.position < 3.9, "video EOF does not end longer audio source");
|
||||
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer audio input drains");
|
||||
require(ended.position >= 3.9 && ended.position < 4.2, "split EOF follows last audio sample");
|
||||
require(player_close(player), "close split stepped source");
|
||||
closed(player);
|
||||
std::puts("PASS split VFR/B-frames: exact bidirectional steps/boundaries/non-keyframe seek/audio resume/independent video EOF");
|
||||
|
||||
require(openNetwork(player, base, "/media/video.mp4", "/media/frame-step-audio.m4a"),
|
||||
"long video plus short independent audio open");
|
||||
require(player_play(player), "queued play with shorter audio");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "asymmetric split autoplay");
|
||||
seekCompleted(player, 1.5, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.3; },
|
||||
"video continues after independent audio EOF");
|
||||
require(player_pause(player), "pause in independent audio tail");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause longer video after audio EOF");
|
||||
seekCompleted(player, 3.2, PLAYER_PAUSED);
|
||||
require(player_play(player), "resume beyond shorter separate audio endpoint");
|
||||
ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer video input drains");
|
||||
require(std::abs(ended.position - 4) < .03, "audio EOF does not truncate video presentation duration");
|
||||
require(player_close(player), "close asymmetric split source");
|
||||
closed(player);
|
||||
std::puts("PASS independent audio EOF: video presentation continues and final duration is retained");
|
||||
}
|
||||
|
||||
static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
require(openNetwork(player, base, "/media/network-vod.m3u8"), "recorded native HLS open");
|
||||
require(player_play(player), "recorded HLS autoplay");
|
||||
auto playing = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PLAYING && !s.buffering && s.position > .2;
|
||||
}, "recorded HLS playback");
|
||||
require(playing.has_audio && playing.has_video && playing.can_seek,
|
||||
"recorded HLS owns its I/O but supports timestamp seeking");
|
||||
require(player_pause(player), "pause native HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "native HLS pause");
|
||||
seekCompleted(player, 2.5, PLAYER_PAUSED);
|
||||
require(player_play(player), "resume native HLS seek");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.6; },
|
||||
"native HLS seek resumes actual audio/video clock");
|
||||
[view draw];
|
||||
require(player_close(player), "close native HLS");
|
||||
closed(player);
|
||||
std::puts("PASS native HLS: selected headers/recorded seekability/paused seek/audio-video resume");
|
||||
}
|
||||
|
||||
static void liveAndReplacement(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
require(openNetwork(player, base, "/media/av.mp4", "", 4, true), "live-flag network open");
|
||||
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "live-flag open");
|
||||
require(!live.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1) &&
|
||||
!player_step_frame(player, -1), "live source rejects seeks and both frame-step directions");
|
||||
require(player_open(player, (fixtures + "/video.mp4").c_str()), "ordinary local source replaces network live source");
|
||||
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "local replacement");
|
||||
require(local.can_seek && !local.buffering && local.has_video && !local.has_audio && !local.seek_pending,
|
||||
"local replacement restores seekability and clears network flags");
|
||||
require(std::abs(player_get_volume(player) - .37f) < .00001f, "local replacement keeps volume");
|
||||
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
||||
require(player_close(player), "close local replacement");
|
||||
closed(player);
|
||||
require(openNetwork(player, base, "/no-range/streaming.mp4", "", 30), "non-range HTTP open");
|
||||
auto noRange = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "non-seekable HTTP open");
|
||||
require(!noRange.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1),
|
||||
"HTTP input without byte ranges rejects seek and step even without live metadata");
|
||||
require(player_play(player), "non-seekable network source still plays");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "non-seekable playback");
|
||||
require(player_close(player), "close non-seekable HTTP source");
|
||||
closed(player);
|
||||
std::puts("PASS live/non-range seek rejection/local replacement/volume/state reset");
|
||||
}
|
||||
|
||||
static void blockedOpening(Player *player, const std::string &base, const std::string &token) {
|
||||
require(openNetwork(player, base, "/stall-open/" + token + "/av.mp4"), "open deliberately stalled HTTP response");
|
||||
until(player, [&](auto s) {
|
||||
auto gate = gateState(base, token);
|
||||
return s.status == PLAYER_OPENING && gate.entered && gate.waiting > 0 && !gate.released;
|
||||
}, "opening response-body read underway");
|
||||
}
|
||||
|
||||
static void blockedPlayback(Player *player, const std::string &base, const std::string &token,
|
||||
bool splitAudio = false) {
|
||||
auto stalled = "/stall/" + token + "/streaming.mp4";
|
||||
require(openNetwork(player, base, splitAudio ? "/media/streaming.mp4" : stalled,
|
||||
splitAudio ? stalled : "", 30), "open bounded streaming prefix");
|
||||
require(player_play(player), "queue play before gated source opens");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; },
|
||||
"stream starts before entire file arrives");
|
||||
until(player, [&](auto s) {
|
||||
auto gate = gateState(base, token);
|
||||
return s.status == PLAYER_PLAYING && s.buffering && gate.entered && gate.waiting > 0 && !gate.released;
|
||||
}, "real demux read blocks at server gate");
|
||||
auto gate = gateState(base, token);
|
||||
require(gate.bytes < gate.total && gate.total > 131072, "engine streams playable prefix without whole-file download");
|
||||
}
|
||||
|
||||
static void cancellation(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
for (bool splitAudio : {false, true}) {
|
||||
const std::string token = splitAudio ? "split-playing-seek" : "pause-seek";
|
||||
blockedPlayback(player, base, token, splitAudio);
|
||||
if (!splitAudio) {
|
||||
require(player_pause(player), "pause while actual network read is blocked");
|
||||
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause does not wait for network read");
|
||||
stillBlocked(base, token);
|
||||
require(std::abs(player_get_state(player).position - paused.position) < .0001, "buffered pause freezes native clock");
|
||||
}
|
||||
seekCompleted(player, 28, splitAudio ? PLAYER_PLAYING : PLAYER_PAUSED);
|
||||
stillBlocked(base, token);
|
||||
auto afterSeek = gateState(base, token);
|
||||
require(afterSeek.requests > 1 && afterSeek.ranges > 1, "seek replaces stalled read with an actual new byte-range request");
|
||||
require(player_play(player), "resume at seek target with stale read still blocked");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1; },
|
||||
"resume after stalled-read seek");
|
||||
stillBlocked(base, token);
|
||||
require(player_close(player), "close resumed formerly stalled source");
|
||||
closed(player);
|
||||
std::puts(splitAudio
|
||||
? "PASS stalled secondary audio reader: split playing seek/both-input acknowledgement/new range/resume without server release"
|
||||
: "PASS stalled demux read: buffering/bounded prefix/pause/paused seek acknowledgement/new range/resume without server release");
|
||||
}
|
||||
|
||||
blockedOpening(player, base, "opening-close");
|
||||
require(player_close(player), "close cancels opening read");
|
||||
closed(player);
|
||||
stillBlocked(base, "opening-close");
|
||||
blockedOpening(player, base, "opening-replace");
|
||||
require(player_open(player, (fixtures + "/audio.wav").c_str()), "local open replaces blocked network open");
|
||||
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED && s.has_audio && !s.has_video; },
|
||||
"local replacement cancels blocked opening read");
|
||||
require(local.can_seek && !local.buffering && !local.seek_pending && local.position == 0,
|
||||
"stale opening read cannot publish over replacement timeline");
|
||||
stillBlocked(base, "opening-replace");
|
||||
require(player_close(player), "close audio-only replacement");
|
||||
closed(player);
|
||||
blockedPlayback(player, base, "playing-close");
|
||||
require(player_close(player), "close cancels blocked playing read");
|
||||
closed(player);
|
||||
stillBlocked(base, "playing-close");
|
||||
blockedPlayback(player, base, "playing-replace");
|
||||
require(openNetwork(player, base, "/media/video.mp4", "/media/audio.wav"),
|
||||
"new network open replaces blocked playing read");
|
||||
auto replacement = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PAUSED && s.has_audio && s.has_video && !s.buffering;
|
||||
},
|
||||
"network replacement cancels blocked playing read");
|
||||
require(replacement.position == 0 && !replacement.buffering && !replacement.seek_pending && replacement.can_seek,
|
||||
"replacement ignores stale stream data and buffering state");
|
||||
stillBlocked(base, "playing-replace");
|
||||
require(player_close(player), "close split network replacement");
|
||||
closed(player);
|
||||
std::puts("PASS stalled opening+playing reads: close/local+network replacement cancel without server release");
|
||||
}
|
||||
|
||||
static void failures(Player *player, const std::string &base) {
|
||||
require(openNetwork(player, base, "/media/av.mp4", "", 4, false, false), "header-denied request accepted asynchronously");
|
||||
auto denied = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "required header denial", true);
|
||||
require(denied.error[0] && !denied.has_audio && !denied.has_video && !denied.can_seek &&
|
||||
!denied.buffering && !denied.seek_pending, "failed network open clears media and reports useful error");
|
||||
NSDictionary *stats = control(base, "/control/stats");
|
||||
require([stats[@"av.mp4"][@"denied"] unsignedLongLongValue] > 0,
|
||||
"fixture server actually rejected request without selected-format header");
|
||||
require(openNetwork(player, base, "/failure/av.mp4"), "controlled failed network open accepted");
|
||||
auto failed = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "HTTP failure", true);
|
||||
require(failed.error[0] && !failed.buffering && !failed.can_seek, "HTTP failure has explicit error and cleared flags");
|
||||
require(openNetwork(player, base, "/media/video.mp4", "/media/audio.wav"), "recover from real HTTP failures");
|
||||
require(player_play(player), "recovered split source autoplay");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "failed network recovery");
|
||||
require(player_close(player), "close recovered network source");
|
||||
closed(player);
|
||||
std::puts("PASS actual HTTP header denial/503 failure/useful errors/cleared state/split-source recovery");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@autoreleasepool {
|
||||
require(argc == 3, "loopback base URL and local fixture directory required");
|
||||
std::string base = argv[1], fixtures = argv[2];
|
||||
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
||||
[NSApplication sharedApplication];
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
require(bool(player), "player creation");
|
||||
require(player_set_volume(player.get(), .37f), "set persistent network playback gain");
|
||||
void *native = player_macos_make_video_view(player.get());
|
||||
require(native != nullptr, "actual Metal renderer initializes");
|
||||
MTKView *view = (__bridge_transfer MTKView *)native;
|
||||
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(-2000, -2000, 320, 180)
|
||||
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
window.contentView = view;
|
||||
[window orderBack:nil];
|
||||
transport(player.get(), view, base, false);
|
||||
transport(player.get(), view, base, true);
|
||||
frameSteps(player.get(), view, base);
|
||||
nativeHLS(player.get(), view, base);
|
||||
liveAndReplacement(player.get(), base, fixtures);
|
||||
cancellation(player.get(), base, fixtures);
|
||||
failures(player.get(), base);
|
||||
blockedPlayback(player.get(), base, "destroy-playing");
|
||||
player.reset(); // Python's process deadline catches a join waiting for server unblock.
|
||||
stillBlocked(base, "destroy-playing");
|
||||
[view draw]; // The native view is allowed to outlive its cancelled engine.
|
||||
[window orderOut:nil];
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> opening(player_create(), player_destroy);
|
||||
require(bool(opening), "second player creation for opening cancellation");
|
||||
blockedOpening(opening.get(), base, "destroy-opening");
|
||||
opening.reset();
|
||||
stillBlocked(base, "destroy-opening");
|
||||
std::puts("PASS stalled opening+playing destruction: joins without server release/native view lifetime");
|
||||
}
|
||||
}
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Range-capable loopback fixtures for the real native network playback check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
|
||||
|
||||
class _Gate:
|
||||
def __init__(self, total: int):
|
||||
self.total = total
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.claimed = False
|
||||
self.waiting = 0
|
||||
self.requests = 0
|
||||
self.ranges = 0
|
||||
self.bytes = 0
|
||||
|
||||
|
||||
class StreamingServer:
|
||||
"""Context-managed server; safe to reuse for actual resolver/playback smoke checks."""
|
||||
|
||||
headers = {
|
||||
"av.mp4": "combined",
|
||||
"streaming.mp4": "combined",
|
||||
"frame-step.mp4": "combined",
|
||||
"video.mp4": "video",
|
||||
"frame-step-video.mp4": "video",
|
||||
"audio.wav": "audio",
|
||||
"frame-step-audio.m4a": "audio",
|
||||
"network-vod.m3u8": "combined",
|
||||
**{f"network-vod-{index}.ts": "combined" for index in range(4)},
|
||||
}
|
||||
|
||||
def __init__(self, fixtures: str | Path):
|
||||
self.fixtures = Path(fixtures).resolve()
|
||||
self.lock = threading.Lock()
|
||||
self.gates: dict[str, _Gate] = {}
|
||||
self.stats: dict[str, dict[str, int]] = {}
|
||||
self.stopping = threading.Event()
|
||||
owner = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass # Media URLs/headers never appear in logs.
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
owner._get(self)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
pass # The engine intentionally cancels active media connections.
|
||||
|
||||
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.http.daemon_threads = True
|
||||
self.thread = threading.Thread(target=self.http.serve_forever, daemon=True)
|
||||
self.base_url = f"http://127.0.0.1:{self.http.server_port}"
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
self.stopping.set()
|
||||
with self.lock:
|
||||
for gate in self.gates.values():
|
||||
gate.release.set()
|
||||
self.http.shutdown()
|
||||
self.http.server_close()
|
||||
self.thread.join()
|
||||
|
||||
@staticmethod
|
||||
def _reply(request, status: int, body: bytes, content_type="application/json"):
|
||||
request.send_response(status)
|
||||
request.send_header("Content-Type", content_type)
|
||||
request.send_header("Content-Length", str(len(body)))
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.wfile.write(body)
|
||||
request.close_connection = True
|
||||
|
||||
@staticmethod
|
||||
def _range(value: str | None, size: int):
|
||||
if value is None:
|
||||
return 0, size - 1
|
||||
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value)
|
||||
if not match or not any(match.groups()):
|
||||
return None
|
||||
first, last = match.groups()
|
||||
if not first:
|
||||
suffix = int(last)
|
||||
return (max(0, size - suffix), size - 1) if suffix else None
|
||||
start = int(first)
|
||||
end = min(int(last), size - 1) if last else size - 1
|
||||
return (start, end) if 0 <= start <= end < size else None
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
parts = unquote(parsed.path).strip("/").split("/")
|
||||
if parts == ["control", "stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps(self.stats).encode()
|
||||
self._reply(request, 200, body)
|
||||
return
|
||||
if len(parts) == 3 and parts[0] == "control" and parts[1] in ("status", "release"):
|
||||
with self.lock:
|
||||
gate = self.gates.get(parts[2])
|
||||
if gate is None:
|
||||
body = {"entered": False, "released": False, "waiting": 0, "requests": 0,
|
||||
"ranges": 0, "bytes": 0, "total": 0}
|
||||
else:
|
||||
if parts[1] == "release":
|
||||
gate.release.set()
|
||||
body = {"entered": gate.entered.is_set(), "released": gate.release.is_set(),
|
||||
"waiting": gate.waiting, "requests": gate.requests, "ranges": gate.ranges,
|
||||
"bytes": gate.bytes, "total": gate.total}
|
||||
self._reply(request, 200, json.dumps(body).encode())
|
||||
return
|
||||
|
||||
mode = parts[0]
|
||||
token = None
|
||||
if len(parts) == 3 and mode in ("stall", "stall-open"):
|
||||
token, name = parts[1:]
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", token):
|
||||
self._reply(request, 400, b"Invalid gate token", "text/plain")
|
||||
return
|
||||
elif len(parts) == 2 and mode in ("media", "no-range", "failure"):
|
||||
name = parts[1]
|
||||
else:
|
||||
self._reply(request, 404, b"Unknown fixture endpoint", "text/plain")
|
||||
return
|
||||
if name not in self.headers or not (self.fixtures / name).is_file():
|
||||
self._reply(request, 404, b"Fixture not found", "text/plain")
|
||||
return
|
||||
with self.lock:
|
||||
stats = self.stats.setdefault(name, {"requests": 0, "ranges": 0, "bytes": 0, "denied": 0})
|
||||
stats["requests"] += 1
|
||||
if request.headers.get("X-Terebi-Fixture") != self.headers[name]:
|
||||
stats["denied"] += 1
|
||||
denied = True
|
||||
else:
|
||||
denied = False
|
||||
if denied:
|
||||
self._reply(request, 403, b"Required fixture header missing or incorrect", "text/plain")
|
||||
return
|
||||
if mode == "failure":
|
||||
self._reply(request, 503, b"Controlled fixture extraction failure", "text/plain")
|
||||
return
|
||||
|
||||
path = self.fixtures / name
|
||||
size = path.stat().st_size
|
||||
range_header = request.headers.get("Range") if mode != "no-range" else None
|
||||
byte_range = self._range(range_header, size)
|
||||
if byte_range is None:
|
||||
request.send_response(416)
|
||||
request.send_header("Content-Range", f"bytes */{size}")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
start, end = byte_range
|
||||
gate = None
|
||||
gate_at = 0
|
||||
should_gate = False
|
||||
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:
|
||||
self._reply(request, 400, b"Invalid gate offset", "text/plain")
|
||||
return
|
||||
with self.lock:
|
||||
gate = self.gates.setdefault(token, _Gate(size))
|
||||
gate.requests += 1
|
||||
gate.ranges += int(range_header is not None)
|
||||
# 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.
|
||||
should_gate = not gate.claimed and start <= gate_at <= end
|
||||
if should_gate:
|
||||
gate.claimed = True
|
||||
with self.lock:
|
||||
stats["ranges"] += int(range_header is not None)
|
||||
request.send_response(206 if range_header is not None else 200)
|
||||
request.send_header("Accept-Ranges", "none" if mode == "no-range" else "bytes")
|
||||
content_types = {".wav": "audio/wav", ".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t"}
|
||||
request.send_header("Content-Type", content_types.get(path.suffix, "video/mp4"))
|
||||
request.send_header("Content-Length", str(end - start + 1))
|
||||
if range_header is not None:
|
||||
request.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.wfile.flush()
|
||||
request.close_connection = True
|
||||
cursor = start
|
||||
with path.open("rb") as media:
|
||||
media.seek(start)
|
||||
while cursor <= end and not self.stopping.is_set():
|
||||
if should_gate and cursor == gate_at:
|
||||
with self.lock:
|
||||
gate.waiting += 1
|
||||
gate.entered.set()
|
||||
try:
|
||||
gate.release.wait() # No timer silently unblocks a cancellation check.
|
||||
finally:
|
||||
with self.lock:
|
||||
gate.waiting -= 1
|
||||
should_gate = False
|
||||
amount = min(16384, end - cursor + 1)
|
||||
if should_gate:
|
||||
amount = min(amount, gate_at - cursor)
|
||||
chunk = media.read(amount)
|
||||
if not chunk:
|
||||
break
|
||||
request.wfile.write(chunk)
|
||||
request.wfile.flush()
|
||||
cursor += len(chunk)
|
||||
with self.lock:
|
||||
stats["bytes"] += len(chunk)
|
||||
if gate is not None:
|
||||
gate.bytes += len(chunk)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
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")
|
||||
missing = [name for name in required if not (args.fixtures / name).is_file()]
|
||||
if missing:
|
||||
parser.error("Missing streaming fixtures: " + ", ".join(missing))
|
||||
with StreamingServer(args.fixtures) as server:
|
||||
try:
|
||||
result = subprocess.run([str(args.binary.resolve()), server.base_url,
|
||||
str(args.fixtures.resolve())], timeout=180, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit("FAIL: native network check exceeded its infrastructure deadline")
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,388 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct YouTubeResolverChecks {
|
||||
struct Failure: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
}
|
||||
|
||||
static let page = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||
static let splitJSON = #"""
|
||||
{
|
||||
"title": "Separate streams", "duration": 19.25, "is_live": false,
|
||||
"http_headers": {"Referer": "https://www.youtube.com/", "User-Agent": "metadata-agent"},
|
||||
"requested_formats": [
|
||||
{"url": "https://media.example/video?token=video", "vcodec": "av01.0.04M.08", "acodec": "none", "dynamic_range": "SDR", "http_headers": {"User-Agent": "video-agent", "X-Input": "video"}},
|
||||
{"url": "https://media.example/audio?token=audio", "vcodec": "none", "acodec": "opus"}
|
||||
]
|
||||
}
|
||||
"""#
|
||||
static let combinedJSON = #"""
|
||||
{"title": "Combined stream", "duration": 12, "is_live": false,
|
||||
"url": "https://media.example/combined", "vcodec": "h264", "acodec": "aac",
|
||||
"http_headers": {"User-Agent": "combined-agent"}}
|
||||
"""#
|
||||
|
||||
static func require(_ condition: @autoclosure () throws -> Bool, _ message: String) throws {
|
||||
if try condition() == false { throw Failure(description: "FAIL: \(message)") }
|
||||
}
|
||||
|
||||
static func reject(_ message: String, _ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch is Failure { throw Failure(description: "FAIL: \(message) assertion failed") }
|
||||
catch { return }
|
||||
throw Failure(description: "FAIL: \(message) was accepted")
|
||||
}
|
||||
|
||||
static func decode(_ json: String) throws -> YouTubeVideo {
|
||||
try YouTubeResolver.decode(Data(json.utf8), webpageURL: page)
|
||||
}
|
||||
|
||||
static func decode(_ object: [String: Any]) throws -> YouTubeVideo {
|
||||
try YouTubeResolver.decode(JSONSerialization.data(withJSONObject: object), webpageURL: page)
|
||||
}
|
||||
|
||||
static func fixture(_ json: String) throws -> [String: Any] {
|
||||
try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
|
||||
}
|
||||
|
||||
static func headerLines(_ text: String) throws -> Set<String> {
|
||||
try require(text.isEmpty || text.hasSuffix("\r\n"), "headers terminate with CRLF")
|
||||
let lines = text.components(separatedBy: "\r\n").filter { !$0.isEmpty }
|
||||
try require(lines.allSatisfy { !$0.contains("\r") && !$0.contains("\n") },
|
||||
"headers contain no bare CR or LF")
|
||||
return Set(lines)
|
||||
}
|
||||
|
||||
static func videoURLs() throws {
|
||||
let valid = [
|
||||
"https://youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVRw&list=PLignored&t=3",
|
||||
"https://m.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://music.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtu.be/jNQXAC9IVRw?si=share",
|
||||
"https://www.youtube.com/shorts/jNQXAC9IVRw",
|
||||
"https://www.youtube.com/live/jNQXAC9IVRw",
|
||||
"https://www.youtube.com/embed/jNQXAC9IVRw"
|
||||
]
|
||||
for input in valid {
|
||||
let url = try YouTubeResolver.validateURL(input)
|
||||
try require(url == page, "supported video URLs identify the same video")
|
||||
}
|
||||
let hostile = [
|
||||
"", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw",
|
||||
"https://example.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com@evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://www.youtube.com/playlist?list=PLexample",
|
||||
"https://www.youtube.com/watch?list=PLexample",
|
||||
"https://www.youtube.com/watch?v=short",
|
||||
"https://youtu.be/jNQXAC9IVRwextra", "https://youtu.be/jNQXAC9IVR!",
|
||||
"https://www.youtube.com/shorts/", "https://youtu.be/jNQXAC9IVRw/extra",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVR%0A",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVR%C3%A9"
|
||||
]
|
||||
for input in hostile {
|
||||
try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) }
|
||||
}
|
||||
let asciiID = try YouTubeResolver.validateURL("https://youtu.be/Abc_123-XYZ")
|
||||
try require(asciiID.absoluteString.hasSuffix("v=Abc_123-XYZ"), "video IDs retain case, underscore and hyphen")
|
||||
}
|
||||
|
||||
static func metadata() throws {
|
||||
let split = try decode(splitJSON)
|
||||
try require(split.title == "Separate streams" && split.duration == 19.25 && !split.isLive,
|
||||
"split streams preserve title, fractional duration and recorded status")
|
||||
try require(split.webpageURL == page, "resolved media retains its webpage URL")
|
||||
try require(split.video.url == "https://media.example/video?token=video"
|
||||
&& split.audio?.url == "https://media.example/audio?token=audio",
|
||||
"video and audio inputs remain distinct and preserve signed query strings")
|
||||
let videoHeaders = try headerLines(split.video.headers)
|
||||
let audioHeaders = try headerLines(split.audio!.headers)
|
||||
try require(videoHeaders == ["User-Agent: video-agent", "X-Input: video"],
|
||||
"selected video headers take precedence as a complete map")
|
||||
try require(audioHeaders == ["Referer: https://www.youtube.com/", "User-Agent: metadata-agent"],
|
||||
"audio without selected headers inherits metadata headers")
|
||||
|
||||
var splitObject = try fixture(splitJSON)
|
||||
var formats = splitObject["requested_formats"] as! [[String: Any]]
|
||||
formats.reverse()
|
||||
formats[0]["http_headers"] = ["X-Input": "audio"]
|
||||
formats[1]["http_headers"] = NSNull()
|
||||
splitObject["requested_formats"] = formats
|
||||
let reversed = try decode(splitObject)
|
||||
let reversedVideoHeaders = try headerLines(reversed.video.headers)
|
||||
let reversedAudioHeaders = try headerLines(reversed.audio!.headers)
|
||||
try require(reversed.video.url == split.video.url && reversed.audio?.url == split.audio?.url,
|
||||
"stream roles depend on codecs rather than array order")
|
||||
try require(reversedVideoHeaders == audioHeaders && reversedAudioHeaders == ["X-Input: audio"],
|
||||
"each input independently selects its own headers or metadata fallback")
|
||||
|
||||
let combined = try decode(combinedJSON)
|
||||
try require(combined.video.url == "https://media.example/combined" && combined.audio == nil,
|
||||
"combined fallback uses one input, not duplicated audio")
|
||||
try require(combined.title == "Combined stream" && combined.duration == 12 && !combined.isLive,
|
||||
"combined metadata is preserved")
|
||||
let combinedHeaders = try headerLines(combined.video.headers)
|
||||
try require(combinedHeaders == ["User-Agent: combined-agent"], "combined input carries headers")
|
||||
|
||||
var selectedCombined = try fixture(combinedJSON)
|
||||
selectedCombined.removeValue(forKey: "url")
|
||||
selectedCombined["requested_formats"] = [
|
||||
["url": "https://media.example/selected-combined", "vcodec": "h264", "acodec": "aac",
|
||||
"http_headers": ["X-Input": "combined"]]
|
||||
]
|
||||
let selected = try decode(selectedCombined)
|
||||
let selectedHeaders = try headerLines(selected.video.headers)
|
||||
try require(selected.audio == nil && selected.video.url == "https://media.example/selected-combined"
|
||||
&& selectedHeaders == ["X-Input: combined"],
|
||||
"a selected combined format is one playable input with its own headers")
|
||||
|
||||
var hls = try fixture(combinedJSON)
|
||||
hls["url"] = "https://media.example/live.m3u8"
|
||||
hls["protocol"] = "m3u8_native"
|
||||
let playlist = try decode(hls)
|
||||
try require(playlist.video.url == "https://media.example/live.m3u8" && playlist.audio == nil,
|
||||
"native HLS remains a usable streaming playlist, not a downloaded file")
|
||||
|
||||
var live = try fixture(combinedJSON)
|
||||
live["is_live"] = true
|
||||
live["duration"] = NSNull()
|
||||
let directLive = try decode(live)
|
||||
try require(directLive.isLive && directLive.duration == 0, "live stream permits unknown duration")
|
||||
live.removeValue(forKey: "is_live")
|
||||
live.removeValue(forKey: "duration")
|
||||
live["live_status"] = "is_live"
|
||||
try require(try decode(live).isLive, "live_status identifies live broadcasts")
|
||||
live["live_status"] = "was_live"
|
||||
try require(!(try decode(live)).isLive, "completed live broadcasts are recorded media")
|
||||
live["live_status"] = "is_upcoming"
|
||||
try reject("upcoming broadcast with no playable live content") { _ = try decode(live) }
|
||||
|
||||
var emptyHeaders = try fixture(combinedJSON)
|
||||
emptyHeaders.removeValue(forKey: "http_headers")
|
||||
try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block")
|
||||
}
|
||||
|
||||
static func malformedMetadata() throws {
|
||||
try reject("non-JSON extractor output") { _ = try decode("not JSON") }
|
||||
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
|
||||
try reject("missing playable stream metadata") { _ = try decode(json) }
|
||||
}
|
||||
try reject("nonfinite duration") {
|
||||
_ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#)
|
||||
}
|
||||
let combined = try fixture(combinedJSON)
|
||||
for key in ["url", "vcodec", "acodec"] {
|
||||
var object = combined
|
||||
object.removeValue(forKey: key)
|
||||
try reject("missing required combined \(key)") { _ = try decode(object) }
|
||||
object[key] = 17
|
||||
try reject("wrong type for combined \(key)") { _ = try decode(object) }
|
||||
}
|
||||
for url in ["", "https://", "file:///tmp/video", "ftp://media.example/video",
|
||||
"data:video/mp4;base64,AA==", "https://media.example/video\r\nX-Injected: yes"] {
|
||||
var object = combined
|
||||
object["url"] = url
|
||||
try reject("invalid or unsafe stream URL") { _ = try decode(object) }
|
||||
}
|
||||
for duration in [-1, "19", true] as [Any] {
|
||||
var object = combined
|
||||
object["duration"] = duration
|
||||
try reject("invalid duration metadata") { _ = try decode(object) }
|
||||
}
|
||||
for key in ["is_live", "title"] {
|
||||
var object = combined
|
||||
object[key] = ["malformed"]
|
||||
try reject("malformed \(key) metadata") { _ = try decode(object) }
|
||||
}
|
||||
var hdr = combined
|
||||
hdr["dynamic_range"] = "HDR10"
|
||||
try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) }
|
||||
for value in ["none", NSNull()] as [Any] {
|
||||
var object = combined
|
||||
object["vcodec"] = value
|
||||
try reject("combined input without video codec") { _ = try decode(object) }
|
||||
}
|
||||
for transport in ["http_dash_segments", "mhtml", "rtmp"] {
|
||||
var object = combined
|
||||
object["protocol"] = transport
|
||||
try reject("fragment assembly or unsupported transport is not a whole playable input") {
|
||||
_ = try decode(object)
|
||||
}
|
||||
}
|
||||
|
||||
let split = try fixture(splitJSON)
|
||||
let formats = split["requested_formats"] as! [[String: Any]]
|
||||
for malformed in ["bad", [formats[0]], [formats[1]], [formats[0], formats[0]],
|
||||
[formats[0], formats[1], formats[1]]] as [Any] {
|
||||
var object = split
|
||||
object["requested_formats"] = malformed
|
||||
try reject("malformed or ambiguous split stream selection") { _ = try decode(object) }
|
||||
}
|
||||
var missingAudio = split
|
||||
var badFormats = formats
|
||||
badFormats[1].removeValue(forKey: "url")
|
||||
missingAudio["requested_formats"] = badFormats
|
||||
try reject("missing separate audio URL is not silently downgraded") { _ = try decode(missingAudio) }
|
||||
|
||||
let badHeaders: [Any] = [
|
||||
"not a header map", ["User-Agent": 5], ["Bad Name": "value"], ["Bad:Name": "value"],
|
||||
["Bad\r\nName": "value"], ["": "value"], ["X-Header": "a\r\nInjected: yes"],
|
||||
["X-Header": "a\nb"], ["X-Header": "a\rb"], ["X-Header": "a\u{0}b"],
|
||||
["X-Header": "a\t b"], ["X-Header": "a\u{7f}b"]
|
||||
]
|
||||
for headers in badHeaders {
|
||||
var object = combined
|
||||
object["http_headers"] = headers
|
||||
try reject("unsafe or malformed metadata headers") { _ = try decode(object) }
|
||||
var perInput = split
|
||||
var selected = formats
|
||||
selected[1]["http_headers"] = headers
|
||||
perInput["requested_formats"] = selected
|
||||
try reject("unsafe or malformed selected audio headers") { _ = try decode(perInput) }
|
||||
}
|
||||
}
|
||||
|
||||
struct Extractor: Sendable {
|
||||
let executable: URL
|
||||
let directory: URL
|
||||
|
||||
init(root: URL, name: String, json: String = YouTubeResolverChecks.splitJSON,
|
||||
body: String = #"/bin/cat "$base/response.json""#) throws {
|
||||
directory = root.appendingPathComponent(name, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
executable = directory.appendingPathComponent("fake-extractor")
|
||||
let script = "#!/bin/sh\nset -eu\nbase=${0%/*}\n: > \"$base/started\"\n" + body + "\n"
|
||||
try Data(script.utf8).write(to: executable)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path)
|
||||
try Data(json.utf8).write(to: directory.appendingPathComponent("response.json"))
|
||||
}
|
||||
|
||||
func marker(_ name: String) -> URL { directory.appendingPathComponent(name) }
|
||||
}
|
||||
|
||||
static func awaitMarker(_ extractor: Extractor, _ name: String = "started") async throws {
|
||||
for _ in 0..<400 {
|
||||
if FileManager.default.fileExists(atPath: extractor.marker(name).path) { return }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
throw Failure(description: "FAIL: extractor did not reach \(name) within the isolated check deadline")
|
||||
}
|
||||
|
||||
static func cancelled(_ task: Task<YouTubeVideo, Error>, _ message: String) async throws {
|
||||
do { _ = try await task.value }
|
||||
catch is CancellationError { return }
|
||||
catch { throw Failure(description: "FAIL: \(message) returned an extraction error instead of cancellation") }
|
||||
throw Failure(description: "FAIL: \(message) returned media instead of cancellation")
|
||||
}
|
||||
|
||||
static func subprocesses(root: URL, runtime: URL) async throws {
|
||||
let successful = try Extractor(root: root, name: "successful split")
|
||||
let result = try await YouTubeResolver.resolve(page, executableURL: successful.executable, runtimeURL: runtime)
|
||||
try require(result.video.url == "https://media.example/video?token=video"
|
||||
&& result.audio?.url == "https://media.example/audio?token=audio"
|
||||
&& result.title == "Separate streams" && result.duration == 19.25,
|
||||
"actual subprocess output becomes usable separate media inputs")
|
||||
|
||||
let denied = try Extractor(root: root, name: "denied", body: #"""
|
||||
/bin/cat "$base/response.json"
|
||||
printf '%s\n' 'ERROR: Sign in to confirm your age: https://media.example/private?secret=not-for-errors' >&2
|
||||
exit 17
|
||||
"""#)
|
||||
do {
|
||||
_ = try await YouTubeResolver.resolve(page, executableURL: denied.executable, runtimeURL: runtime)
|
||||
throw Failure(description: "FAIL: failed extractor returned media despite its exit status")
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = String(describing: error) + error.localizedDescription
|
||||
try require(text.lowercased().contains("sign-in") || text.lowercased().contains("sign in"),
|
||||
"extractor stderr produces an actionable access error")
|
||||
try require(!text.contains("not-for-errors") && !text.contains("media.example"),
|
||||
"extractor error does not leak signed media URLs or credentials")
|
||||
}
|
||||
|
||||
let noisy = try Extractor(root: root, name: "pipe boundaries", body: #"""
|
||||
/bin/dd if=/dev/zero bs=1024 count=512 2>/dev/null | /usr/bin/tr '\000' x >&2
|
||||
/bin/dd if=/dev/zero bs=1024 count=512 2>/dev/null | /usr/bin/tr '\000' ' '
|
||||
/bin/cat "$base/response.json"
|
||||
"""#)
|
||||
let drained = try await YouTubeResolver.resolve(page, executableURL: noisy.executable, runtimeURL: runtime)
|
||||
try require(drained.audio?.url == result.audio?.url && drained.video.url == result.video.url,
|
||||
"stderr beyond its capture boundary and stdout beyond pipe capacity drain without deadlock")
|
||||
|
||||
let oversized = try Extractor(root: root, name: "oversized stdout", body: #"""
|
||||
/bin/dd if=/dev/zero bs=1024 count=17408 2>/dev/null | /usr/bin/tr '\000' ' '
|
||||
/bin/cat "$base/response.json"
|
||||
"""#)
|
||||
do {
|
||||
_ = try await YouTubeResolver.resolve(page, executableURL: oversized.executable, runtimeURL: runtime)
|
||||
throw Failure(description: "FAIL: oversized extractor output was accepted")
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = (String(describing: error) + error.localizedDescription).lowercased()
|
||||
try require(text.contains("output") || text.contains("limit") || text.contains("large") || text.contains("metadata"),
|
||||
"bounded stdout rejection explains the output limit")
|
||||
}
|
||||
|
||||
let beforeLaunch = try Extractor(root: root, name: "already cancelled")
|
||||
let preCancelled = Task {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await YouTubeResolver.resolve(page, executableURL: beforeLaunch.executable, runtimeURL: runtime)
|
||||
}
|
||||
try await cancelled(preCancelled, "cancellation before launch")
|
||||
try require(!FileManager.default.fileExists(atPath: beforeLaunch.marker("started").path),
|
||||
"a pre-cancelled request never starts the dependency")
|
||||
|
||||
let waitingScript = #"""
|
||||
trap '' TERM INT
|
||||
printf '%s\n' "$$" > "$base/pid"
|
||||
: > "$base/ready"
|
||||
while [ ! -f "$base/release" ]; do /bin/sleep 0.01; done
|
||||
/bin/cat "$base/response.json"
|
||||
"""#
|
||||
let old = try Extractor(root: root, name: "old request", body: waitingScript)
|
||||
let newer = try Extractor(root: root, name: "new request", json: combinedJSON, body: waitingScript)
|
||||
let oldTask = Task { try await YouTubeResolver.resolve(page, executableURL: old.executable, runtimeURL: runtime) }
|
||||
defer { oldTask.cancel() }
|
||||
try await awaitMarker(old, "ready")
|
||||
let newTask = Task { try await YouTubeResolver.resolve(page, executableURL: newer.executable, runtimeURL: runtime) }
|
||||
defer { newTask.cancel() }
|
||||
try await awaitMarker(newer, "ready")
|
||||
oldTask.cancel()
|
||||
try await cancelled(oldTask, "in-flight cancellation")
|
||||
let oldPID = try String(contentsOf: old.marker("pid"), encoding: .utf8)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try require(pid_t(oldPID).map { Darwin.kill($0, 0) == -1 && errno == ESRCH } == true,
|
||||
"cancelled extractor is reaped even when it ignores termination")
|
||||
oldTask.cancel()
|
||||
try Data().write(to: newer.marker("release"))
|
||||
let newResult = try await newTask.value
|
||||
try require(newResult.title == "Combined stream" && newResult.audio == nil
|
||||
&& newResult.video.url == "https://media.example/combined",
|
||||
"cancelling an old request cannot terminate or replace a newer resolution")
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
// A broken pipe/cancellation implementation must fail, not leave this check hanging.
|
||||
let watchdog = Task {
|
||||
try await Task.sleep(for: .seconds(30))
|
||||
fatalError("FAIL: resolver checks exceeded their bounded subprocess deadline")
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("terebi resolver checks '\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let runtime = root.appendingPathComponent("deno")
|
||||
try FileManager.default.createSymbolicLink(at: runtime, withDestinationURL: URL(fileURLWithPath: "/usr/bin/true"))
|
||||
try videoURLs()
|
||||
try metadata()
|
||||
try malformedMetadata()
|
||||
try await subprocesses(root: root, runtime: runtime)
|
||||
print("YouTube resolver checks passed")
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,16 @@ ARCH := $(shell uname -m)
|
||||
CXXFLAGS := -std=c++20 -mmacosx-version-min=15.0 -O2 -g -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
|
||||
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore
|
||||
OBJECTS := .build/player.o .build/Output.o
|
||||
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift
|
||||
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift
|
||||
APP := .build/terebi.app
|
||||
|
||||
.PHONY: all run check check-audio check-window
|
||||
.PHONY: all run check check-fixtures check-audio check-window check-youtube check-network
|
||||
all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist
|
||||
|
||||
.build:
|
||||
mkdir -p $@
|
||||
|
||||
.build/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Makefile | .build
|
||||
.build/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Makefile | .build
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
.build/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build
|
||||
@@ -36,7 +36,10 @@ run: all
|
||||
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
check: .build/playback-check check-audio check-window
|
||||
check: .build/playback-check check-fixtures check-audio check-window check-youtube check-network
|
||||
.build/playback-check .build/fixtures
|
||||
|
||||
check-fixtures:
|
||||
mkdir -p .build/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 4 -c:v mpeg4 -bf 2 -c:a aac .build/fixtures/av.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4
|
||||
@@ -45,7 +48,6 @@ check: .build/playback-check check-audio check-window
|
||||
# Literal frame PTS: 0,.1,.2,.3,.4,.65,.9,1.15,1.4,1.5,1.6,1.7; reordered B-frames across short GOPs.
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=10:duration=1.2 -f lavfi -i sine=frequency=440:sample_rate=44100:duration=2 -vf "settb=1/1000,setpts='if(lt(N,5),N*100,if(lt(N,9),400+(N-4)*250,1400+(N-8)*100))'" -fps_mode vfr -enc_time_base:v 1:1000 -c:v mpeg4 -g 4 -bf 2 -video_track_timescale 1000 -c:a aac .build/fixtures/frame-step.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 .build/fixtures/hdr.mkv
|
||||
.build/playback-check .build/fixtures
|
||||
|
||||
.build/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
|
||||
@@ -72,3 +74,19 @@ check-audio: .build/audio-continuity-check
|
||||
|
||||
check-window: .build/compact-window-check
|
||||
.build/compact-window-check
|
||||
|
||||
.build/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | .build
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
|
||||
|
||||
check-youtube: .build/youtube-resolver-check
|
||||
.build/youtube-resolver-check
|
||||
|
||||
.build/network-playback-check: Checks/network-playback.mm $(OBJECTS) Makefile
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
check-network: .build/network-playback-check check-fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/frame-step.mp4 -an -c:v copy .build/fixtures/frame-step-video.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/frame-step.mp4 -vn -c:a copy .build/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/fixtures/streaming.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/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/fixtures/network-vod-%d.ts .build/fixtures/network-vod.m3u8
|
||||
python3 Checks/streaming.py .build/network-playback-check .build/fixtures
|
||||
|
||||
@@ -16,6 +16,20 @@ The local app is `.build/terebi.app`. It links the installed FFmpeg libraries; i
|
||||
|
||||
Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open With**. Successfully opened files start playback automatically, including when replacing the current file. **⇧⌘W** closes the file; closing the window quits and joins the worker.
|
||||
|
||||
## YouTube
|
||||
|
||||
YouTube playback optionally requires [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, and a supported JavaScript runtime. Homebrew provides the extractor and its components; Deno is recommended:
|
||||
|
||||
```sh
|
||||
brew install yt-dlp deno
|
||||
```
|
||||
|
||||
Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a YouTube video link onto the window. Watch, short-link, Shorts, live, and embed URLs identify one video; playlists and channels are not opened. Resolution is cancellable by closing or replacing the media. Finder launches also search the usual Homebrew executable locations.
|
||||
|
||||
yt-dlp selects the best available SDR video and audio, including separate streams, with a combined-stream fallback. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
|
||||
|
||||
The 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.
|
||||
|
||||
## Playback UI
|
||||
|
||||
The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point frosted titlebar with native traffic lights and file icon, plus a dark frosted 440 × 68-point floating controller. AppKit materials blur the video beneath the chrome; muted icons, an orange volume track, a narrow seek thumb, and zero-padded time labels follow the reference. Shared vertical padding and an optical offset for symbol/slider insets balance the controller's visible top and bottom margins in both play and pause states. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size.
|
||||
@@ -50,11 +64,13 @@ 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. `seek_pending` is set synchronously when a seek or frame step is accepted and clears when all queued seeks/steps on that timeline finish. `player_step_frame` accepts only `-1` or `+1`, requires ready video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and uses a lock-free target gain. Callers own the opaque handle and must stop using it before destruction.
|
||||
- `Sources/Core/player.cpp`: single demux/decode worker, bounded buffers, timestamps, audio-master synchronization, paused previews, exact frame stepping, decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes all old timeline state before resuming.
|
||||
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, and live status. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
|
||||
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes both selected inputs' old timeline state before resuming.
|
||||
- `Sources/Core/network_input.hpp`: network-only demux readers with bounded encoded packet queues, asynchronous coordinated seeks, fifteen-second I/O deadlines, and interruption on seek/replacement/close/destruction. Remote input protocols exclude local-file access through manifests.
|
||||
- `Sources/Core/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 panel/Finder/drop handling, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. The shared `PlayerModel.open` path enqueues play after an accepted open, on the new file's command timeline; the C interface still opens paused for callers that need explicit playback control.
|
||||
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. Both accepted open paths enqueue play on the new source's command timeline; the C interface still opens paused for callers that need explicit control.
|
||||
- `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.
|
||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, SDR stream selection and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
||||
- `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency.
|
||||
|
||||
## Runnable check
|
||||
@@ -69,6 +85,10 @@ Generates short fixtures and exercises the real playback interface and native ou
|
||||
|
||||
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, isolated preference persistence, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
|
||||
|
||||
`make check-youtube` checks URL/metadata/header trust boundaries and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. It needs neither installed yt-dlp/Deno nor external network access.
|
||||
|
||||
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. Both new checks are included in `make check`.
|
||||
|
||||
Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks.
|
||||
|
||||
Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio.
|
||||
@@ -77,8 +97,10 @@ Visual correctness, perceived A/V synchronization, and control usability still n
|
||||
|
||||
## Current limits
|
||||
|
||||
Local regular files, automatically selected best audio/video tracks, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, selectable tracks, VideoToolbox decoding, rotation/deinterlacing, or network playback yet. Mid-track audio format changes report an error.
|
||||
Local regular files and YouTube video URLs, automatically selected best audio/video tracks, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, selectable tracks, VideoToolbox decoding, or rotation/deinterlacing. Mid-track audio format changes report an error. The URL UI accepts YouTube videos rather than arbitrary web pages or streaming services.
|
||||
|
||||
Forward frame stepping retains the live video decoder cursor instead of seeking and re-decoding the same keyframe group for each repeat. Backward stepping still scans from an earlier keyframe; long-GOP or unindexed files can therefore step backward slowly, without blanking the image or allocating a command per key repeat. Audio is skipped while stepping and rebuilt once on resume from the verified preroll anchor; resuming after a long forward run can also require a longer preroll. Intermediate frames needed by inter-frame dependencies are decoded and discarded; frames are not cached across the entire file. Every accepted step counts. Stepping requires usable decoded presentation timestamps and reports an error rather than inventing frame times when they are absent.
|
||||
|
||||
Audio buffering is bounded to one second plus one decoded block (up to four seconds); video is bounded to 64 frames/128 MiB. Extremely interleaved files can drop far-future video frames to avoid starving audio; the ceiling and upgrade path are marked at `queueVideo()`. Device/stream latency is accounted for, but device changes during playback are not handled yet.
|
||||
|
||||
Network 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.
|
||||
|
||||
+151
-28
@@ -10,6 +10,8 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@Published private(set) var commandError: String?
|
||||
@Published private(set) var pendingRevision: UInt64?
|
||||
@Published private(set) var resolvingYouTube = false
|
||||
@Published private(set) var isLiveStream = false
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -40,6 +42,8 @@ final class PlayerModel: ObservableObject {
|
||||
private var lastAudibleVolume = 1.0
|
||||
private var needsVideoFit = false
|
||||
private var seekPending = false
|
||||
private var youtubeTask: Task<Void, Never>?
|
||||
private var youtubeRequest: UUID?
|
||||
|
||||
private init() {
|
||||
let player = player_create()
|
||||
@@ -58,20 +62,20 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var isBusy: Bool { pendingRevision != nil || state.status == PLAYER_OPENING }
|
||||
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
|
||||
var canTogglePlayback: Bool {
|
||||
handle != nil && !isBusy &&
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
||||
}
|
||||
var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 }
|
||||
var canStepFrame: Bool { canTogglePlayback && state.has_video && !isSeeking }
|
||||
var canOpen: Bool { handle != nil && !isBusy }
|
||||
var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY }
|
||||
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
|
||||
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
|
||||
var canOpen: Bool { handle != nil }
|
||||
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
|
||||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||||
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
|
||||
var errorText: String? {
|
||||
if let commandError { return commandError }
|
||||
guard state.status == PLAYER_ERROR else { return nil }
|
||||
guard !resolvingYouTube, state.status == PLAYER_ERROR else { return nil }
|
||||
var error = state.error
|
||||
return withUnsafeBytes(of: &error) { bytes in
|
||||
let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
|
||||
@@ -91,13 +95,120 @@ final class PlayerModel: ObservableObject {
|
||||
open(url)
|
||||
}
|
||||
|
||||
func open(_ url: URL) {
|
||||
guard canOpen, let handle else { return }
|
||||
guard url.isFileURL else {
|
||||
commandError = "Only local media files are supported."
|
||||
func openYouTubePanel() {
|
||||
guard canOpen else { return }
|
||||
revealControls()
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Open YouTube Video"
|
||||
alert.informativeText = "Paste a YouTube video URL."
|
||||
alert.addButton(withTitle: "Open")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
|
||||
field.placeholderString = "https://www.youtube.com/watch?v=…"
|
||||
field.setAccessibilityLabel("YouTube video URL")
|
||||
alert.accessoryView = field
|
||||
alert.window.initialFirstResponder = field
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
openYouTube(field.stringValue)
|
||||
}
|
||||
|
||||
func openYouTube(_ text: String) {
|
||||
guard let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
let url: URL
|
||||
do {
|
||||
url = try YouTubeResolver.validateURL(text)
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
if !player_pause(handle) {
|
||||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
let request = UUID()
|
||||
youtubeRequest = request
|
||||
resolvingYouTube = true
|
||||
commandError = nil
|
||||
revealControls()
|
||||
youtubeTask = Task { [weak self] in
|
||||
do {
|
||||
let video = try await YouTubeResolver.resolve(url)
|
||||
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)
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
|
||||
self.youtubeTask = nil
|
||||
self.youtubeRequest = nil
|
||||
self.resolvingYouTube = false
|
||||
self.commandError = error.localizedDescription
|
||||
self.revealControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelYouTubeResolution() {
|
||||
youtubeRequest = nil
|
||||
youtubeTask?.cancel()
|
||||
youtubeTask = nil
|
||||
resolvingYouTube = false
|
||||
}
|
||||
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo) {
|
||||
guard let handle else { return }
|
||||
let revision = player_get_state(handle).revision
|
||||
let accepted = video.video.url.withCString { videoURL in
|
||||
video.video.headers.withCString { videoHeaders in
|
||||
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
|
||||
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
|
||||
video_headers: videoHeaders, audio_headers: audioHeaders,
|
||||
duration: video.duration, is_live: video.isLive)
|
||||
return player_open_network(handle, &source)
|
||||
}
|
||||
if let audio = video.audio {
|
||||
return audio.url.withCString { audioURL in
|
||||
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
|
||||
}
|
||||
}
|
||||
return enqueue(audioURL: nil, audioHeaders: nil)
|
||||
}
|
||||
}
|
||||
guard accepted else {
|
||||
commandError = "Could not queue the YouTube video to open. Please try again."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, live: video.isLive)
|
||||
}
|
||||
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false) {
|
||||
guard let handle else { return }
|
||||
pendingRevision = revision
|
||||
fileName = title
|
||||
fileIcon = icon
|
||||
isLiveStream = live
|
||||
needsVideoFit = true
|
||||
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
isSeeking = false
|
||||
seekPending = false
|
||||
seekPosition = 0
|
||||
window?.representedURL = url
|
||||
window?.title = title
|
||||
revealControls()
|
||||
}
|
||||
|
||||
func open(_ url: URL) {
|
||||
guard canOpen, let handle else { return }
|
||||
guard url.isFileURL else {
|
||||
openYouTube(url.absoluteString)
|
||||
return
|
||||
}
|
||||
cancelYouTubeResolution()
|
||||
let revision = player_get_state(handle).revision
|
||||
let accepted = url.withUnsafeFileSystemRepresentation { path in
|
||||
guard let path else { return false }
|
||||
@@ -107,17 +218,8 @@ final class PlayerModel: ObservableObject {
|
||||
commandError = "Could not queue the file to open. Please try again."
|
||||
return
|
||||
}
|
||||
pendingRevision = revision
|
||||
fileName = url.lastPathComponent
|
||||
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
|
||||
needsVideoFit = true
|
||||
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
isSeeking = false
|
||||
seekPending = false
|
||||
seekPosition = 0
|
||||
window?.representedURL = url
|
||||
window?.title = url.lastPathComponent
|
||||
revealControls()
|
||||
didAcceptOpen(revision: revision, title: url.lastPathComponent,
|
||||
icon: NSWorkspace.shared.icon(forFile: url.path), url: url)
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
@@ -139,6 +241,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func closeFile() {
|
||||
guard let handle, canClose else { return }
|
||||
cancelYouTubeResolution()
|
||||
guard player_close(handle) else {
|
||||
commandError = "Could not queue closing the file. Please try again."
|
||||
return
|
||||
@@ -146,6 +249,7 @@ final class PlayerModel: ObservableObject {
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
isLiveStream = false
|
||||
needsVideoFit = false
|
||||
window?.aspectRatio = .zero
|
||||
commandError = nil
|
||||
@@ -339,6 +443,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
cancelYouTubeResolution()
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
@@ -558,6 +663,7 @@ private struct PlayerContent: View {
|
||||
private var actionsMenu: some View {
|
||||
Menu {
|
||||
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
|
||||
Button("Close File", action: model.closeFile).disabled(!model.canClose)
|
||||
Divider()
|
||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||
@@ -651,13 +757,16 @@ private struct PlayerContent: View {
|
||||
showsRemainingTime.toggle()
|
||||
model.revealControls()
|
||||
} label: {
|
||||
Text(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration))
|
||||
Text(model.isLiveStream ? "LIVE" :
|
||||
(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration)))
|
||||
.frame(minWidth: 32, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(showsRemainingTime ? "Show Duration" : "Show Remaining Time")
|
||||
.accessibilityLabel(showsRemainingTime ? "Remaining Time" : "Duration")
|
||||
.accessibilityValue(time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
|
||||
.disabled(model.isLiveStream)
|
||||
.help(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Show Duration" : "Show Remaining Time"))
|
||||
.accessibilityLabel(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Remaining Time" : "Duration"))
|
||||
.accessibilityValue(model.isLiveStream ? "Live" :
|
||||
time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
|
||||
}
|
||||
.font(.system(size: 11).monospacedDigit())
|
||||
.padding(.horizontal, 16)
|
||||
@@ -707,7 +816,8 @@ private struct PlayerContent: View {
|
||||
} else if model.isBusy {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text("Opening…").font(.callout).foregroundStyle(.secondary)
|
||||
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if model.fileName == nil {
|
||||
@@ -715,9 +825,11 @@ private struct PlayerContent: View {
|
||||
Image(systemName: "play.rectangle").font(.system(size: 56, weight: .ultraLight))
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
Text("terebi").font(.system(size: 22, weight: .medium))
|
||||
Text("Drop a media file here").font(.callout).foregroundStyle(.secondary)
|
||||
Text("Drop a media file or YouTube link here").font(.callout).foregroundStyle(.secondary)
|
||||
Button("Open File…", action: model.openPanel)
|
||||
.buttonStyle(.bordered).controlSize(.large).disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel)
|
||||
.buttonStyle(.bordered).disabled(!model.canOpen)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if !model.state.has_video && model.state.has_audio {
|
||||
@@ -729,6 +841,13 @@ private struct PlayerContent: View {
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
if model.state.buffering && !model.isBusy && model.errorText == nil {
|
||||
ProgressView("Buffering…")
|
||||
.padding(12)
|
||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
if model.isCompact {
|
||||
Text(model.fileName ?? "terebi")
|
||||
@@ -782,7 +901,8 @@ private struct PlayerContent: View {
|
||||
}
|
||||
}
|
||||
.dropDestination(for: URL.self) { urls, _ in
|
||||
guard model.canOpen, let url = urls.first, url.isFileURL else { return false }
|
||||
guard model.canOpen, let url = urls.first else { return false }
|
||||
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false }
|
||||
model.open(url)
|
||||
return true
|
||||
} isTargeted: {
|
||||
@@ -974,6 +1094,9 @@ struct TerebiApp: App {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("Open File…", action: model.openPanel).keyboardShortcut("o")
|
||||
.disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel)
|
||||
.keyboardShortcut("o", modifiers: [.command, .shift])
|
||||
.disabled(!model.canOpen)
|
||||
Button("Close File", action: model.closeFile)
|
||||
.keyboardShortcut("w", modifiers: [.command, .shift])
|
||||
.disabled(!model.canClose)
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import Foundation
|
||||
import Dispatch
|
||||
import Darwin
|
||||
|
||||
struct YouTubeStream: Sendable {
|
||||
let url: String
|
||||
let headers: String
|
||||
}
|
||||
|
||||
struct YouTubeVideo: Sendable {
|
||||
let title: String
|
||||
let video: YouTubeStream
|
||||
let audio: YouTubeStream?
|
||||
let duration: Double
|
||||
let isLive: Bool
|
||||
let webpageURL: URL
|
||||
}
|
||||
|
||||
private struct YouTubeResolutionError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
enum YouTubeResolver {
|
||||
fileprivate static let outputLimit = 16 * 1024 * 1024
|
||||
|
||||
static func validateURL(_ text: String) throws -> URL {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == (scheme == "https" ? 443 : 80),
|
||||
let host = components.host?.lowercased() else {
|
||||
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.")
|
||||
}
|
||||
let id: String?
|
||||
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
if host == "youtu.be", path.count == 2, path[0].isEmpty {
|
||||
id = String(path[1])
|
||||
} else if ["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"].contains(host) {
|
||||
if components.path == "/watch" {
|
||||
let videoIDs = components.queryItems?.filter { $0.name == "v" } ?? []
|
||||
id = videoIDs.count == 1 ? videoIDs[0].value : nil
|
||||
} else if path.count == 3, path[0].isEmpty, ["shorts", "live", "embed"].contains(String(path[1])) {
|
||||
id = String(path[2])
|
||||
} else {
|
||||
id = nil
|
||||
}
|
||||
} else {
|
||||
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.")
|
||||
}
|
||||
guard let id, id.utf8.count == 11,
|
||||
id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else {
|
||||
throw YouTubeResolutionError(message: "The YouTube URL must identify one video, not a playlist or channel.")
|
||||
}
|
||||
return URL(string: "https://www.youtube.com/watch?v=\(id)")!
|
||||
}
|
||||
|
||||
static func decode(_ data: Data, webpageURL: URL) throws -> YouTubeVideo {
|
||||
guard data.count <= outputLimit else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
|
||||
}
|
||||
let metadata: YouTubeMetadata
|
||||
do {
|
||||
metadata = try JSONDecoder().decode(YouTubeMetadata.self, from: data)
|
||||
} catch {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned malformed video metadata. Update yt-dlp and try again.")
|
||||
}
|
||||
guard metadata._type == nil || metadata._type == "video" else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp did not resolve a single playable video.")
|
||||
}
|
||||
guard metadata.live_status != "is_upcoming" else {
|
||||
throw YouTubeResolutionError(message: "This livestream has not started yet.")
|
||||
}
|
||||
let duration = metadata.duration ?? 0
|
||||
guard duration.isFinite, duration >= 0 else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned an invalid video duration.")
|
||||
}
|
||||
let videoFormat: YouTubeMetadata
|
||||
let audioFormat: YouTubeMetadata?
|
||||
if let formats = metadata.requested_formats {
|
||||
if formats.count == 1, formats[0].hasVideo, formats[0].hasAudio {
|
||||
videoFormat = formats[0]
|
||||
audioFormat = nil
|
||||
} else if formats.count == 2,
|
||||
let video = formats.first(where: { $0.hasVideo && $0.acodec == "none" }),
|
||||
let audio = formats.first(where: { $0.hasAudio && $0.vcodec == "none" }) {
|
||||
videoFormat = video
|
||||
audioFormat = audio
|
||||
} else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp did not select a supported video and audio stream pair.")
|
||||
}
|
||||
} else {
|
||||
guard metadata.hasVideo, metadata.hasAudio else {
|
||||
throw YouTubeResolutionError(message: "No supported combined video and audio stream was returned.")
|
||||
}
|
||||
videoFormat = metadata
|
||||
audioFormat = nil
|
||||
}
|
||||
guard videoFormat.dynamic_range == nil || videoFormat.dynamic_range?.uppercased() == "SDR" else {
|
||||
throw YouTubeResolutionError(message: "This video has no supported SDR stream. HDR playback is not supported.")
|
||||
}
|
||||
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
|
||||
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
||||
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return YouTubeVideo(
|
||||
title: title?.isEmpty == false ? title! : "YouTube video",
|
||||
video: video, audio: audio, duration: duration,
|
||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
||||
webpageURL: try validateURL(webpageURL.absoluteString)
|
||||
)
|
||||
}
|
||||
|
||||
static func resolve(_ url: URL, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
||||
try Task.checkCancellation()
|
||||
let webpageURL = try validateURL(url.absoluteString)
|
||||
let directories = searchDirectories()
|
||||
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
|
||||
?? findExecutable("yt-dlp", directories: directories)
|
||||
guard let executable else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Install it with ‘brew install yt-dlp deno’, then try again.")
|
||||
}
|
||||
let runtime: URL?
|
||||
if let runtimeURL {
|
||||
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
|
||||
} else {
|
||||
runtime = ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
|
||||
}
|
||||
guard let runtime,
|
||||
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Install Deno with ‘brew install deno’. Supported runtimes are Deno, Node, QuickJS, and Bun.")
|
||||
}
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = directories.joined(separator: ":")
|
||||
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
||||
let arguments = [
|
||||
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
||||
"--no-progress", "--color", "never", "--no-check-formats",
|
||||
"--no-js-runtimes", "--js-runtimes", "\(runtimeName):\(runtime.path)",
|
||||
"--socket-timeout", "20", "--extractor-retries", "0", "--retries", "0",
|
||||
"--format", "bestvideo[dynamic_range=?SDR]\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(transport)",
|
||||
"--", webpageURL.absoluteString
|
||||
]
|
||||
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
||||
try Task.checkCancellation()
|
||||
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
|
||||
throw error
|
||||
}
|
||||
let video = try decode(output.stdout, webpageURL: webpageURL)
|
||||
try Task.checkCancellation()
|
||||
return video
|
||||
}
|
||||
|
||||
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
|
||||
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
|
||||
}
|
||||
guard let text = format.url,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let url = components.url else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned an invalid HTTP stream URL.")
|
||||
}
|
||||
var headers = ""
|
||||
for (name, value) in (format.http_headers ?? fallbackHeaders ?? [:]).sorted(by: { $0.key < $1.key }) {
|
||||
guard !name.isEmpty,
|
||||
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
|
||||
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) || CharacterSet.newlines.contains($0) }) else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned unsafe HTTP stream headers.")
|
||||
}
|
||||
headers += "\(name): \(value)\r\n"
|
||||
}
|
||||
return YouTubeStream(url: url.absoluteString, headers: headers)
|
||||
}
|
||||
|
||||
private static func searchDirectories() -> [String] {
|
||||
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
||||
var result: [String] = []
|
||||
for path in paths where path.hasPrefix("/") && !result.contains(path) { result.append(path) }
|
||||
return result
|
||||
}
|
||||
|
||||
private static func findExecutable(_ name: String, directories: [String]) -> URL? {
|
||||
for directory in directories {
|
||||
let url = URL(fileURLWithPath: directory).appendingPathComponent(name)
|
||||
if isExecutableFile(url) { return url }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isExecutableFile(_ url: URL) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
return url.isFileURL && FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
||||
&& !isDirectory.boolValue && FileManager.default.isExecutableFile(atPath: url.path)
|
||||
}
|
||||
|
||||
private static func executableFile(_ url: URL, name: String) throws -> URL {
|
||||
guard isExecutableFile(url) else {
|
||||
throw YouTubeResolutionError(message: "The \(name) executable is missing or is not executable.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
fileprivate static func dependencyError(_ text: String) -> YouTubeResolutionError? {
|
||||
if text.contains("yt-dlp-ejs") || text.contains("challenge solving failed") || text.contains("no supported javascript") || text.contains("no javascript runtime") {
|
||||
return YouTubeResolutionError(message: "YouTube extraction requires current yt-dlp, its EJS components, and a supported JavaScript runtime. Run ‘brew upgrade yt-dlp deno’. For a pip installation, install ‘yt-dlp[default]’ and Deno.")
|
||||
}
|
||||
if text.contains("no such option") || text.contains("unrecognized arguments") {
|
||||
return YouTubeResolutionError(message: "The installed yt-dlp is too old for YouTube streaming. Update it with ‘brew upgrade yt-dlp’.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fileprivate static func extractionError(_ data: Data, status: Int32) -> YouTubeResolutionError {
|
||||
let text = String(decoding: data, as: UTF8.self).lowercased()
|
||||
if let error = dependencyError(text) { return error }
|
||||
// Never include extractor output: it can contain signed URLs or credentials.
|
||||
if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") {
|
||||
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
|
||||
}
|
||||
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
|
||||
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Update yt-dlp and try again.")
|
||||
}
|
||||
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
|
||||
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.")
|
||||
}
|
||||
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
|
||||
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; update yt-dlp if the problem persists.")
|
||||
}
|
||||
return YouTubeResolutionError(message: "yt-dlp could not resolve this video (exit status \(status)). Update yt-dlp and Deno, then try again.")
|
||||
}
|
||||
}
|
||||
|
||||
private struct YouTubeMetadata: Decodable {
|
||||
let _type: String?
|
||||
let title: String?
|
||||
let duration: Double?
|
||||
let is_live: Bool?
|
||||
let live_status: String?
|
||||
let url: String?
|
||||
let `protocol`: String?
|
||||
let vcodec: String?
|
||||
let acodec: String?
|
||||
let dynamic_range: String?
|
||||
let http_headers: [String: String]?
|
||||
let requested_formats: [YouTubeMetadata]?
|
||||
|
||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
}
|
||||
|
||||
private struct YouTubeExtractionOutput: Sendable {
|
||||
let stdout: Data
|
||||
let stderr: Data
|
||||
}
|
||||
|
||||
// All Process, pipe, and continuation state belongs to this request's serial queue.
|
||||
private final class YouTubeExtraction: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "terebi.youtube-extraction", qos: .userInitiated)
|
||||
private let process = Process()
|
||||
private let stdoutPipe = Pipe()
|
||||
private let stderrPipe = Pipe()
|
||||
private var stdoutSource: (any DispatchSourceRead)?
|
||||
private var stderrSource: (any DispatchSourceRead)?
|
||||
private var continuation: CheckedContinuation<YouTubeExtractionOutput, any Error>?
|
||||
private var stdout = Data()
|
||||
private var stderr = Data()
|
||||
private var scratch = [UInt8](repeating: 0, count: 16 * 1024)
|
||||
private var cancelled = false
|
||||
private var launched = false
|
||||
private var exited = false
|
||||
private var stdoutDone = false
|
||||
private var stderrDone = false
|
||||
private var finished = false
|
||||
private var failure: (any Error)?
|
||||
|
||||
init(executable: URL, arguments: [String], environment: [String: String]) {
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
process.environment = environment
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
}
|
||||
|
||||
func run() async throws -> YouTubeExtractionOutput {
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async { self.start(continuation) }
|
||||
}
|
||||
} onCancel: {
|
||||
self.queue.async { self.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private func start(_ continuation: CheckedContinuation<YouTubeExtractionOutput, any Error>) {
|
||||
self.continuation = continuation
|
||||
guard !cancelled else {
|
||||
finish(.failure(CancellationError()))
|
||||
return
|
||||
}
|
||||
let stdoutFD = stdoutPipe.fileHandleForReading.fileDescriptor
|
||||
let stderrFD = stderrPipe.fileHandleForReading.fileDescriptor
|
||||
for descriptor in [stdoutFD, stderrFD] {
|
||||
let flags = fcntl(descriptor, F_GETFL)
|
||||
guard flags >= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) >= 0 else {
|
||||
finish(.failure(YouTubeResolutionError(message: "Could not create the yt-dlp output pipes.")))
|
||||
return
|
||||
}
|
||||
}
|
||||
stdoutSource = DispatchSource.makeReadSource(fileDescriptor: stdoutFD, queue: queue)
|
||||
stderrSource = DispatchSource.makeReadSource(fileDescriptor: stderrFD, queue: queue)
|
||||
stdoutSource?.setEventHandler { [weak self] in self?.drain(isStdout: true) }
|
||||
stderrSource?.setEventHandler { [weak self] in self?.drain(isStdout: false) }
|
||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = stderrPipe.fileHandleForReading
|
||||
stdoutSource?.setCancelHandler { try? stdoutHandle.close() }
|
||||
stderrSource?.setCancelHandler { try? stderrHandle.close() }
|
||||
stdoutSource?.resume()
|
||||
stderrSource?.resume()
|
||||
process.terminationHandler = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.queue.async { self.didExit() }
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
launched = true
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
} catch {
|
||||
finish(.failure(YouTubeResolutionError(message: "Could not launch yt-dlp. Verify that yt-dlp is installed and executable.")))
|
||||
}
|
||||
}
|
||||
|
||||
private func drain(isStdout: Bool) {
|
||||
guard !finished, !(isStdout ? stdoutDone : stderrDone) else { return }
|
||||
let handle = isStdout ? stdoutPipe.fileHandleForReading : stderrPipe.fileHandleForReading
|
||||
for _ in 0..<64 {
|
||||
let count = scratch.withUnsafeMutableBytes { Darwin.read(handle.fileDescriptor, $0.baseAddress!, $0.count) }
|
||||
if count > 0 {
|
||||
if isStdout {
|
||||
guard count <= YouTubeResolver.outputLimit - stdout.count else {
|
||||
failure = YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
scratch.withUnsafeBytes { stdout.append($0.bindMemory(to: UInt8.self).baseAddress!, count: count) }
|
||||
} else {
|
||||
let retained = min(count, 256 * 1024 - stderr.count)
|
||||
if retained > 0 {
|
||||
scratch.withUnsafeBytes { stderr.append($0.bindMemory(to: UInt8.self).baseAddress!, count: retained) }
|
||||
}
|
||||
}
|
||||
} else if count == 0 {
|
||||
closeStream(isStdout: isStdout)
|
||||
completeIfReady()
|
||||
return
|
||||
} else if errno == EINTR {
|
||||
continue
|
||||
} else if errno == EAGAIN || errno == EWOULDBLOCK {
|
||||
return
|
||||
} else {
|
||||
failure = YouTubeResolutionError(message: "Could not read yt-dlp output.")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Yield between batches so continuous extractor output cannot starve cancellation.
|
||||
queue.async { self.drain(isStdout: isStdout) }
|
||||
}
|
||||
|
||||
private func didExit() {
|
||||
exited = true
|
||||
drain(isStdout: true)
|
||||
drain(isStdout: false)
|
||||
completeIfReady()
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
guard !finished else { return }
|
||||
cancelled = true
|
||||
failure = CancellationError()
|
||||
stop()
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
closeStream(isStdout: true)
|
||||
closeStream(isStdout: false)
|
||||
if launched, !exited, process.isRunning {
|
||||
process.terminate()
|
||||
queue.asyncAfter(deadline: .now() + .milliseconds(250)) {
|
||||
if !self.exited, self.process.isRunning {
|
||||
Darwin.kill(self.process.processIdentifier, SIGKILL)
|
||||
}
|
||||
}
|
||||
}
|
||||
completeIfReady()
|
||||
}
|
||||
|
||||
private func closeStream(isStdout: Bool) {
|
||||
if isStdout {
|
||||
guard !stdoutDone else { return }
|
||||
stdoutDone = true
|
||||
if let source = stdoutSource {
|
||||
source.cancel()
|
||||
stdoutSource = nil
|
||||
} else {
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
}
|
||||
} else {
|
||||
guard !stderrDone else { return }
|
||||
stderrDone = true
|
||||
if let source = stderrSource {
|
||||
source.cancel()
|
||||
stderrSource = nil
|
||||
} else {
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func completeIfReady() {
|
||||
guard !finished, continuation != nil, (!launched || exited), stdoutDone, stderrDone else { return }
|
||||
if let failure {
|
||||
finish(.failure(failure))
|
||||
} else if process.terminationStatus != 0 {
|
||||
finish(.failure(YouTubeResolver.extractionError(stderr, status: process.terminationStatus)))
|
||||
} else {
|
||||
finish(.success(YouTubeExtractionOutput(stdout: stdout, stderr: stderr)))
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<YouTubeExtractionOutput, any Error>) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
process.terminationHandler = nil
|
||||
closeStream(isStdout: true)
|
||||
closeStream(isStdout: false)
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
let completion = continuation
|
||||
continuation = nil
|
||||
completion?.resume(with: result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
#pragma once
|
||||
|
||||
extern "C" {
|
||||
#include <libavformat/avformat.h>
|
||||
}
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cerrno>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <thread>
|
||||
|
||||
namespace playback {
|
||||
// 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;
|
||||
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) {}
|
||||
~NetworkInput() {
|
||||
stop();
|
||||
for (auto &lane : lanes)
|
||||
for (auto *packet : lane.packets) av_packet_free(&packet);
|
||||
av_packet_free(&scratch);
|
||||
avformat_close_input(&format);
|
||||
}
|
||||
NetworkInput(const NetworkInput &) = delete;
|
||||
NetworkInput &operator=(const NetworkInput &) = delete;
|
||||
|
||||
static int interrupt(void *opaque) {
|
||||
auto &input = *static_cast<NetworkInput *>(opaque);
|
||||
return input.cancelled.load(std::memory_order_relaxed) ||
|
||||
input.stopping.load(std::memory_order_relaxed) ||
|
||||
input.epoch.load(std::memory_order_relaxed) != input.activeEpoch ||
|
||||
input.serial.load(std::memory_order_acquire) != input.operationSerial.load(std::memory_order_relaxed) ||
|
||||
now() >= input.deadline.load(std::memory_order_relaxed);
|
||||
}
|
||||
void armIO() {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
scratch = av_packet_alloc();
|
||||
if (!scratch) throw std::bad_alloc();
|
||||
reader = std::thread([this] { run(); });
|
||||
}
|
||||
void stop() {
|
||||
cancelled.store(true, std::memory_order_relaxed);
|
||||
changed.notify_all();
|
||||
if (reader.joinable()) reader.join();
|
||||
}
|
||||
void seek(int streamIndex, int64_t timestamp) {
|
||||
std::lock_guard lock(mutex);
|
||||
for (auto &lane : lanes) clear(lane);
|
||||
ended = false;
|
||||
failure = 0;
|
||||
seekIndex = streamIndex;
|
||||
seekTimestamp = timestamp;
|
||||
seeking = true;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
changed.notify_all();
|
||||
}
|
||||
void discardAudio(bool discard) {
|
||||
std::lock_guard lock(mutex);
|
||||
skipAudio = discard;
|
||||
if (discard) clear(lanes[0]);
|
||||
changed.notify_all();
|
||||
}
|
||||
void finish() {
|
||||
std::lock_guard lock(mutex);
|
||||
for (auto &lane : lanes) clear(lane);
|
||||
ended = true;
|
||||
seeking = false;
|
||||
failure = 0;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
changed.notify_all();
|
||||
}
|
||||
bool seekPending() const {
|
||||
std::lock_guard lock(mutex);
|
||||
return seeking;
|
||||
}
|
||||
int error() const {
|
||||
std::lock_guard lock(mutex);
|
||||
return failure;
|
||||
}
|
||||
// 1: packet, 0: not yet available, AVERROR_EOF: this input is drained.
|
||||
int take(int slot, AVPacket *destination) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (failure) return failure;
|
||||
auto &lane = lanes[slot];
|
||||
if (lane.count) {
|
||||
auto *packet = lane.packets[lane.first];
|
||||
lane.bytes -= packetBytes(packet);
|
||||
av_packet_move_ref(destination, packet);
|
||||
lane.first = (lane.first + 1) % packetLimit;
|
||||
--lane.count;
|
||||
changed.notify_one();
|
||||
return 1;
|
||||
}
|
||||
return ended ? AVERROR_EOF : 0;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Lane {
|
||||
std::array<AVPacket *, packetLimit> packets{};
|
||||
int index = -1;
|
||||
std::size_t first = 0, count = 0, bytes = 0;
|
||||
};
|
||||
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;
|
||||
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;
|
||||
int seekIndex = -1, failure = 0;
|
||||
int64_t seekTimestamp = 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;
|
||||
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;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
static void clear(Lane &lane) {
|
||||
while (lane.count) {
|
||||
av_packet_unref(lane.packets[lane.first]);
|
||||
lane.first = (lane.first + 1) % packetLimit;
|
||||
--lane.count;
|
||||
}
|
||||
lane.first = lane.bytes = 0;
|
||||
}
|
||||
bool stoppingNow() const {
|
||||
return cancelled.load(std::memory_order_relaxed) ||
|
||||
stopping.load(std::memory_order_relaxed) || epoch.load(std::memory_order_relaxed) != activeEpoch;
|
||||
}
|
||||
void run() noexcept {
|
||||
try {
|
||||
while (!stoppingNow()) {
|
||||
uint64_t current;
|
||||
int index;
|
||||
int64_t timestamp;
|
||||
bool doSeek;
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
|
||||
return stoppingNow() || seeking || !ended;
|
||||
});
|
||||
if (stoppingNow()) return;
|
||||
if (ended && !seeking) continue;
|
||||
current = serial.load(std::memory_order_acquire);
|
||||
doSeek = seeking;
|
||||
index = seekIndex;
|
||||
timestamp = seekTimestamp;
|
||||
}
|
||||
operationSerial.store(current, std::memory_order_relaxed);
|
||||
deadline.store(now() + 15'000'000'000LL, std::memory_order_relaxed);
|
||||
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);
|
||||
if (result >= 0 && format->pb) {
|
||||
// An interrupted read leaves AVIO's error sticky even after a successful format seek.
|
||||
format->pb->error = 0;
|
||||
format->pb->eof_reached = 0;
|
||||
}
|
||||
std::lock_guard lock(mutex);
|
||||
if (serial.load(std::memory_order_acquire) != current) continue;
|
||||
seeking = false;
|
||||
if (result < 0) { failure = result; ended = true; }
|
||||
playerWake.notify_one();
|
||||
if (result < 0) continue;
|
||||
}
|
||||
const int result = av_read_frame(format, scratch);
|
||||
std::unique_lock lock(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));
|
||||
continue;
|
||||
}
|
||||
if (result < 0) {
|
||||
av_packet_unref(scratch);
|
||||
ended = true;
|
||||
if (result != AVERROR_EOF) failure = result;
|
||||
playerWake.notify_one();
|
||||
continue;
|
||||
}
|
||||
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; }
|
||||
auto &lane = lanes[slot];
|
||||
const auto bytes = packetBytes(scratch);
|
||||
if (bytes > byteLimit) {
|
||||
failure = AVERROR(ENOBUFS);
|
||||
ended = true;
|
||||
av_packet_unref(scratch);
|
||||
playerWake.notify_one();
|
||||
continue;
|
||||
}
|
||||
changed.wait(lock, [&] {
|
||||
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
|
||||
(slot == 0 && skipAudio) ||
|
||||
(lane.count < packetLimit && lane.bytes + bytes <= byteLimit);
|
||||
});
|
||||
if (stoppingNow()) return;
|
||||
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
|
||||
av_packet_unref(scratch);
|
||||
continue;
|
||||
}
|
||||
lane.bytes += bytes;
|
||||
av_packet_move_ref(lane.packets[(lane.first + lane.count) % packetLimit], scratch);
|
||||
++lane.count;
|
||||
playerWake.notify_one();
|
||||
}
|
||||
} catch (...) {
|
||||
std::lock_guard lock(mutex);
|
||||
failure = AVERROR(ENOMEM);
|
||||
ended = true;
|
||||
playerWake.notify_one();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+431
-104
@@ -26,8 +26,11 @@ extern "C" {
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <arpa/inet.h>
|
||||
#include <vector>
|
||||
#include "network_input.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr int sampleRate = 48000;
|
||||
@@ -53,11 +56,18 @@ struct VideoFrame {
|
||||
struct Track {
|
||||
AVCodecContext *codec = nullptr;
|
||||
int index = -1;
|
||||
AVFormatContext *format = nullptr;
|
||||
playback::NetworkInput *input = nullptr;
|
||||
AVPacket *packet = nullptr;
|
||||
bool packetPending = false;
|
||||
bool receiving = false;
|
||||
bool sourceEnded = false;
|
||||
bool drainSent = false;
|
||||
bool drained = false;
|
||||
double nextPts = 0;
|
||||
int64_t countedTimestamp = AV_NOPTS_VALUE;
|
||||
uint64_t timestampOccurrence = 0;
|
||||
AVStream *stream() const { return format->streams[index]; }
|
||||
};
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close };
|
||||
struct Command {
|
||||
@@ -67,6 +77,12 @@ struct Command {
|
||||
int direction = 0;
|
||||
uint64_t epoch = 0;
|
||||
uint64_t repetitions = 1;
|
||||
std::string audioURL{};
|
||||
std::string videoHeaders{};
|
||||
std::string audioHeaders{};
|
||||
double sourceDuration = 0;
|
||||
bool network = false;
|
||||
bool live = false;
|
||||
};
|
||||
|
||||
void copyError(char *destination, const char *message) noexcept {
|
||||
@@ -77,6 +93,75 @@ int64_t presentationTimestamp(const AVFrame *frame) {
|
||||
return frame->best_effort_timestamp != AV_NOPTS_VALUE ?
|
||||
frame->best_effort_timestamp : frame->pts;
|
||||
}
|
||||
|
||||
bool validNetworkURL(const char *text) {
|
||||
if (!text) return false;
|
||||
const std::string_view url(text);
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
for (unsigned char value : url)
|
||||
if (value <= 32 || value == 127 || value == '\\') return false;
|
||||
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
|
||||
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
|
||||
auto host = authority;
|
||||
std::string_view port;
|
||||
if (host.front() == '[') {
|
||||
const auto end = host.find(']');
|
||||
if (end == std::string_view::npos || end == 1) return false;
|
||||
if (end + 1 < host.size()) {
|
||||
if (host[end + 1] != ':') return false;
|
||||
port = host.substr(end + 2);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
host = host.substr(1, end - 1);
|
||||
std::array<char, INET6_ADDRSTRLEN> address{};
|
||||
if (host.size() >= address.size()) return false;
|
||||
std::memcpy(address.data(), host.data(), host.size());
|
||||
in6_addr parsed{};
|
||||
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
|
||||
} else {
|
||||
const auto colon = host.find(':');
|
||||
if (colon != std::string_view::npos) {
|
||||
port = host.substr(colon + 1);
|
||||
host = host.substr(0, colon);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
if (host.empty()) return false;
|
||||
for (unsigned char value : host)
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
|
||||
}
|
||||
unsigned number = 0;
|
||||
for (unsigned char value : port) {
|
||||
if (value < '0' || value > '9') return false;
|
||||
number = number * 10 + value - '0';
|
||||
if (number > 65535) return false;
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
|
||||
bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
if (headers.size() > 65536) return false;
|
||||
std::size_t offset = 0;
|
||||
while (offset < headers.size()) {
|
||||
const auto end = headers.find("\r\n", offset);
|
||||
if (end == std::string_view::npos) return false;
|
||||
const auto line = headers.substr(offset, end - offset);
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string_view::npos || colon == 0) return false;
|
||||
for (unsigned char value : line.substr(0, colon))
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
|
||||
for (unsigned char value : line.substr(colon + 1))
|
||||
if (value < 32 || value == 127) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
struct Player {
|
||||
@@ -93,8 +178,12 @@ struct Player {
|
||||
std::atomic<uint64_t> requestedEpoch{0};
|
||||
uint64_t activeEpoch = 0;
|
||||
|
||||
// Everything below, including FFmpeg and Output audio operations, is worker-owned.
|
||||
// Decoders, timeline and output are worker-owned; network readers exclusively own demux I/O.
|
||||
AVFormatContext *format = nullptr;
|
||||
std::array<std::unique_ptr<playback::NetworkInput>, 2> inputs;
|
||||
bool network = false;
|
||||
bool canSeek = false;
|
||||
bool buffering = false;
|
||||
std::array<Track, 2> tracks;
|
||||
AVPacket *packet = nullptr;
|
||||
AVFrame *decoded = nullptr;
|
||||
@@ -105,7 +194,7 @@ struct Player {
|
||||
bool resamplerDrained = false;
|
||||
bool packetPending = false;
|
||||
bool demuxEnded = false;
|
||||
int receiving = -1;
|
||||
int nextDecodeSlot = 0;
|
||||
std::deque<VideoFrame> video;
|
||||
std::size_t videoBytes = 0;
|
||||
VideoFrame presented{};
|
||||
@@ -144,9 +233,10 @@ struct Player {
|
||||
bool enqueue(Command command) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (stopping.load(std::memory_order_relaxed)) return false;
|
||||
if (command.action == Action::Step &&
|
||||
(!state.has_video || publishedEpoch != requestedEpoch.load(std::memory_order_relaxed) ||
|
||||
(state.status != PLAYER_PAUSED && state.status != PLAYER_PLAYING && state.status != PLAYER_ENDED)))
|
||||
const bool ready = publishedEpoch == requestedEpoch.load(std::memory_order_relaxed) &&
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED);
|
||||
if (command.action == Action::Seek && ready && !state.can_seek) return false;
|
||||
if (command.action == Action::Step && (!ready || !state.has_video || !state.can_seek))
|
||||
return false;
|
||||
const bool newTimeline = command.action == Action::Open || command.action == Action::Close;
|
||||
command.epoch = requestedEpoch.load(std::memory_order_relaxed) + (newTimeline ? 1 : 0);
|
||||
@@ -182,9 +272,11 @@ struct Player {
|
||||
copyError(requestedError.data(), message);
|
||||
errorRequested = true;
|
||||
errorEpoch = requestedEpoch.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
const auto revision = state.revision + 1;
|
||||
state = {};
|
||||
state.status = PLAYER_ERROR;
|
||||
copyError(state.error, message);
|
||||
++state.revision;
|
||||
state.revision = revision;
|
||||
wake.notify_one();
|
||||
} catch (...) {
|
||||
// No exception may cross the C boundary, including a failed mutex operation.
|
||||
@@ -215,14 +307,18 @@ struct Player {
|
||||
state.duration = duration;
|
||||
state.has_audio = tracks[0].codec != nullptr;
|
||||
state.has_video = tracks[1].codec != nullptr;
|
||||
state.can_seek = canSeek;
|
||||
state.buffering = buffering;
|
||||
// Commands remain pending while executing; publication acknowledges only completed work.
|
||||
state.seek_pending = std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
|
||||
state.seek_pending = networkSeeksPending() ||
|
||||
std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
|
||||
return (command.action == Action::Seek || command.action == Action::Step) &&
|
||||
command.epoch == activeEpoch;
|
||||
});
|
||||
state.video_aspect_ratio = 0;
|
||||
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
|
||||
const auto aspect = av_guess_sample_aspect_ratio(format, format->streams[tracks[1].index], nullptr);
|
||||
const auto &track = tracks[1];
|
||||
const auto aspect = av_guess_sample_aspect_ratio(track.format, track.stream(), nullptr);
|
||||
state.video_aspect_ratio = double(video->width) / video->height *
|
||||
(aspect.num > 0 && aspect.den > 0 ? av_q2d(aspect) : 1);
|
||||
}
|
||||
@@ -232,6 +328,7 @@ struct Player {
|
||||
|
||||
void release() {
|
||||
output.pause();
|
||||
for (auto &input : inputs) if (input) input->stop();
|
||||
output.reset();
|
||||
video.clear();
|
||||
videoBytes = 0;
|
||||
@@ -243,16 +340,20 @@ struct Player {
|
||||
av_channel_layout_uninit(&inputLayout);
|
||||
for (auto &track : tracks) {
|
||||
avcodec_free_context(&track.codec);
|
||||
av_packet_free(&track.packet);
|
||||
track = {};
|
||||
}
|
||||
av_packet_free(&packet);
|
||||
av_frame_free(&decoded);
|
||||
avformat_close_input(&format);
|
||||
if (!network) avformat_close_input(&format);
|
||||
for (auto &input : inputs) input.reset();
|
||||
format = nullptr;
|
||||
network = canSeek = buffering = false;
|
||||
inputRate = 0;
|
||||
inputFormat = AV_SAMPLE_FMT_NONE;
|
||||
pcmFrames = pcmOffset = 0;
|
||||
packetPending = demuxEnded = resamplerDrained = false;
|
||||
receiving = -1;
|
||||
nextDecodeSlot = 0;
|
||||
origin = duration = position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = 0;
|
||||
previewShown = false;
|
||||
error.fill(0);
|
||||
@@ -269,18 +370,23 @@ struct Player {
|
||||
publish();
|
||||
}
|
||||
|
||||
void openTrack(int slot, AVMediaType type, int related) {
|
||||
void openTrack(int slot, AVFormatContext *source, playback::NetworkInput *input,
|
||||
AVMediaType type, int related) {
|
||||
const AVCodec *decoder = nullptr;
|
||||
int index = av_find_best_stream(format, type, -1, related, &decoder, 0);
|
||||
int index = av_find_best_stream(source, type, -1, related, &decoder, 0);
|
||||
if (index == AVERROR_STREAM_NOT_FOUND) return;
|
||||
check(index, type == AVMEDIA_TYPE_AUDIO ? "Finding audio decoder" : "Finding video decoder");
|
||||
auto &track = tracks[slot];
|
||||
track.index = index;
|
||||
track.format = source;
|
||||
track.input = input;
|
||||
track.packet = av_packet_alloc();
|
||||
if (!track.packet) throw std::bad_alloc();
|
||||
track.codec = avcodec_alloc_context3(decoder);
|
||||
if (!track.codec) throw std::bad_alloc();
|
||||
check(avcodec_parameters_to_context(track.codec, format->streams[index]->codecpar),
|
||||
check(avcodec_parameters_to_context(track.codec, source->streams[index]->codecpar),
|
||||
"Reading codec parameters");
|
||||
track.codec->pkt_timebase = format->streams[index]->time_base;
|
||||
track.codec->pkt_timebase = source->streams[index]->time_base;
|
||||
// A bounded software decoder pool; FFmpeg owns its internal frame reorder buffer.
|
||||
track.codec->thread_count = 2;
|
||||
check(avcodec_open2(track.codec, decoder, nullptr), "Opening decoder");
|
||||
@@ -293,9 +399,11 @@ struct Player {
|
||||
if (!track.codec) continue;
|
||||
avcodec_flush_buffers(track.codec);
|
||||
track.drainSent = track.drained = false;
|
||||
av_packet_unref(track.packet);
|
||||
track.packetPending = track.receiving = track.sourceEnded = false;
|
||||
track.countedTimestamp = AV_NOPTS_VALUE;
|
||||
track.timestampOccurrence = 0;
|
||||
const AVStream *stream = format->streams[track.index];
|
||||
const AVStream *stream = track.stream();
|
||||
track.nextPts = stream->start_time == AV_NOPTS_VALUE ? target :
|
||||
std::max(target, stream->start_time * av_q2d(stream->time_base) - origin);
|
||||
}
|
||||
@@ -303,7 +411,7 @@ struct Player {
|
||||
av_channel_layout_uninit(&inputLayout);
|
||||
inputRate = 0;
|
||||
inputFormat = AV_SAMPLE_FMT_NONE;
|
||||
av_packet_unref(packet);
|
||||
if (packet) av_packet_unref(packet);
|
||||
av_frame_unref(decoded);
|
||||
video.clear();
|
||||
videoBytes = 0;
|
||||
@@ -313,22 +421,67 @@ struct Player {
|
||||
steppingAnchor = AV_NOPTS_VALUE;
|
||||
pcmFrames = pcmOffset = 0;
|
||||
packetPending = demuxEnded = resamplerDrained = false;
|
||||
receiving = -1;
|
||||
nextDecodeSlot = 0;
|
||||
position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = target;
|
||||
previewShown = false;
|
||||
steadyPosition = target;
|
||||
steadyAnchor = Clock::now();
|
||||
}
|
||||
|
||||
void open(const std::string &path) {
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers) {
|
||||
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake);
|
||||
auto &input = *inputs[slot];
|
||||
input.format = avformat_alloc_context();
|
||||
if (!input.format) throw std::bad_alloc();
|
||||
input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input};
|
||||
AVDictionary *options = nullptr;
|
||||
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
if (result >= 0) result = av_dict_set(&options, "rw_timeout", "15000000", 0);
|
||||
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
|
||||
if (result < 0) {
|
||||
av_dict_free(&options);
|
||||
check(result, "Configuring network input");
|
||||
}
|
||||
input.armIO();
|
||||
result = avformat_open_input(&input.format, url.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
check(result, "Opening network media");
|
||||
input.armIO();
|
||||
check(avformat_find_stream_info(input.format, nullptr), "Reading network media information");
|
||||
return &input;
|
||||
}
|
||||
|
||||
bool networkSeeksPending() const {
|
||||
for (const auto &input : inputs)
|
||||
if (input && input->seekPending()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void open(const Command &command) {
|
||||
release();
|
||||
network = command.network;
|
||||
status = PLAYER_OPENING;
|
||||
publish();
|
||||
if (network) {
|
||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders);
|
||||
format = primary->format;
|
||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||
if (command.audioURL.empty()) {
|
||||
openTrack(0, format, primary, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
||||
} else {
|
||||
if (!tracks[1].codec)
|
||||
throw std::runtime_error("The selected video stream contains no playable video");
|
||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders);
|
||||
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
|
||||
if (!tracks[0].codec)
|
||||
throw std::runtime_error("The selected audio stream contains no playable audio");
|
||||
}
|
||||
} else {
|
||||
std::error_code filesystemError;
|
||||
if (!std::filesystem::is_regular_file(path, filesystemError))
|
||||
if (!std::filesystem::is_regular_file(command.path, filesystemError))
|
||||
throw std::runtime_error(filesystemError ? filesystemError.message() :
|
||||
"Open requires a local regular media file");
|
||||
const std::string localPath = std::filesystem::absolute(path).string();
|
||||
const std::string localPath = std::filesystem::absolute(command.path).string();
|
||||
format = avformat_alloc_context();
|
||||
if (!format) throw std::bad_alloc();
|
||||
format->interrupt_callback = {interrupt, this};
|
||||
@@ -342,40 +495,56 @@ struct Player {
|
||||
av_dict_free(&options);
|
||||
check(result, "Opening media");
|
||||
check(avformat_find_stream_info(format, nullptr), "Reading media information");
|
||||
openTrack(1, AVMEDIA_TYPE_VIDEO, -1);
|
||||
openTrack(0, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
||||
if (!tracks[0].codec && !tracks[1].codec)
|
||||
throw std::runtime_error("The file contains no playable audio or video track");
|
||||
for (unsigned i = 0; i < format->nb_streams; ++i) {
|
||||
if (static_cast<int>(i) != tracks[0].index && static_cast<int>(i) != tracks[1].index)
|
||||
format->streams[i]->discard = AVDISCARD_ALL;
|
||||
openTrack(1, format, nullptr, AVMEDIA_TYPE_VIDEO, -1);
|
||||
openTrack(0, format, nullptr, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
||||
}
|
||||
if (format->start_time != AV_NOPTS_VALUE) {
|
||||
origin = static_cast<double>(format->start_time) / AV_TIME_BASE;
|
||||
} else {
|
||||
if (!tracks[0].codec && !tracks[1].codec)
|
||||
throw std::runtime_error("The source contains no playable audio or video track");
|
||||
double first = std::numeric_limits<double>::infinity();
|
||||
double measuredDuration = 0;
|
||||
bool seekable = true;
|
||||
for (int source = 0; source < (inputs[1] ? 2 : 1); ++source) {
|
||||
auto *context = network ? inputs[source]->format : format;
|
||||
std::array<int, 2> indices{-1, -1};
|
||||
for (int slot = 0; slot < 2; ++slot)
|
||||
if (tracks[slot].codec && tracks[slot].format == context) indices[slot] = tracks[slot].index;
|
||||
for (unsigned i = 0; i < context->nb_streams; ++i)
|
||||
if (static_cast<int>(i) != indices[0] && static_cast<int>(i) != indices[1])
|
||||
context->streams[i]->discard = AVDISCARD_ALL;
|
||||
if (context->start_time != AV_NOPTS_VALUE) {
|
||||
first = std::min(first, static_cast<double>(context->start_time) / AV_TIME_BASE);
|
||||
} else {
|
||||
for (const auto &track : tracks) {
|
||||
if (!track.codec) continue;
|
||||
const AVStream *stream = format->streams[track.index];
|
||||
if (!track.codec || track.format != context) continue;
|
||||
const auto *stream = track.stream();
|
||||
if (stream->start_time != AV_NOPTS_VALUE)
|
||||
first = std::min(first, stream->start_time * av_q2d(stream->time_base));
|
||||
}
|
||||
origin = std::isfinite(first) ? first : 0;
|
||||
}
|
||||
if (format->duration != AV_NOPTS_VALUE && format->duration > 0)
|
||||
duration = static_cast<double>(format->duration) / AV_TIME_BASE;
|
||||
if (duration == 0) {
|
||||
if (context->duration != AV_NOPTS_VALUE && context->duration > 0)
|
||||
measuredDuration = std::max(measuredDuration, static_cast<double>(context->duration) / AV_TIME_BASE);
|
||||
// Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks.
|
||||
seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) &&
|
||||
(context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 :
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0);
|
||||
}
|
||||
// One origin for both inputs: independently zeroing them would erase real A/V offsets.
|
||||
origin = std::isfinite(first) ? first : 0;
|
||||
duration = network && command.sourceDuration > 0 ? command.sourceDuration : measuredDuration;
|
||||
if (duration == 0 && !command.live) {
|
||||
for (const auto &track : tracks) {
|
||||
if (!track.codec) continue;
|
||||
const AVStream *stream = format->streams[track.index];
|
||||
const AVStream *stream = track.stream();
|
||||
if (stream->duration == AV_NOPTS_VALUE || stream->duration <= 0) continue;
|
||||
const double start = stream->start_time == AV_NOPTS_VALUE ? origin :
|
||||
stream->start_time * av_q2d(stream->time_base);
|
||||
duration = std::max(duration, start - origin + stream->duration * av_q2d(stream->time_base));
|
||||
}
|
||||
}
|
||||
canSeek = !command.live && duration > 0 && seekable;
|
||||
if (tracks[1].codec) {
|
||||
const AVRational rate = av_guess_frame_rate(format, format->streams[tracks[1].index], nullptr);
|
||||
const auto &track = tracks[1];
|
||||
const AVRational rate = av_guess_frame_rate(track.format, track.stream(), nullptr);
|
||||
videoFrameTime = rate.num > 0 && rate.den > 0 ? av_q2d(av_inv_q(rate)) : 1.0 / 25;
|
||||
}
|
||||
if (tracks[0].codec) {
|
||||
@@ -383,16 +552,25 @@ struct Player {
|
||||
if (!output.configure_audio(deviceError))
|
||||
throw std::runtime_error(deviceError.empty() ? "Cannot configure audio output" : deviceError);
|
||||
}
|
||||
packet = av_packet_alloc();
|
||||
if (!network) packet = av_packet_alloc();
|
||||
decoded = av_frame_alloc();
|
||||
if (!packet || !decoded) throw std::bad_alloc();
|
||||
if ((!network && !packet) || !decoded) throw std::bad_alloc();
|
||||
resetTimeline(0);
|
||||
if (network) {
|
||||
for (auto &input : inputs) {
|
||||
if (!input) continue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
status = PLAYER_PAUSED;
|
||||
publish();
|
||||
}
|
||||
|
||||
double clockPosition() const {
|
||||
if (status != PLAYER_PLAYING) return position;
|
||||
if (status != PLAYER_PLAYING || buffering) return position;
|
||||
if (tracks[0].codec) {
|
||||
const double audioClock = output.clock();
|
||||
return std::isfinite(audioClock) ? std::max(seekTarget, audioClock) : position;
|
||||
@@ -402,9 +580,13 @@ struct Player {
|
||||
|
||||
void start() {
|
||||
if (!format || status == PLAYER_ERROR || status == PLAYER_EMPTY) return;
|
||||
if (status == PLAYER_ENDED) seek(0, false);
|
||||
if (status == PLAYER_ENDED) {
|
||||
if (!canSeek) return;
|
||||
seek(0, false);
|
||||
}
|
||||
if (status == PLAYER_PLAYING) return;
|
||||
if (frameStepping) {
|
||||
for (auto &input : inputs) if (input) input->discardAudio(false);
|
||||
VideoFrame selected = std::move(presented);
|
||||
const int64_t timestamp = presentationTimestamp(selected.frame.get());
|
||||
const uint64_t occurrence = selected.timestampOccurrence;
|
||||
@@ -416,7 +598,7 @@ struct Player {
|
||||
skipPresentedTimestamp = timestamp;
|
||||
skipPresentedOccurrence = occurrence;
|
||||
}
|
||||
if (tracks[0].codec) {
|
||||
if (!network && tracks[0].codec) {
|
||||
std::string deviceError;
|
||||
if (!output.start(deviceError))
|
||||
throw std::runtime_error(deviceError.empty() ? "Cannot start audio output" : deviceError);
|
||||
@@ -424,6 +606,7 @@ struct Player {
|
||||
steadyPosition = position;
|
||||
steadyAnchor = Clock::now();
|
||||
status = PLAYER_PLAYING;
|
||||
buffering = network;
|
||||
}
|
||||
|
||||
void pause() {
|
||||
@@ -435,29 +618,85 @@ struct Player {
|
||||
if (std::isfinite(frozen)) position = std::max(seekTarget, frozen);
|
||||
}
|
||||
status = PLAYER_PAUSED;
|
||||
buffering = false;
|
||||
}
|
||||
|
||||
double audioEnd() const {
|
||||
const auto &track = tracks[0];
|
||||
if (!track.codec) return NAN;
|
||||
const auto *stream = track.stream();
|
||||
if (stream->duration != AV_NOPTS_VALUE && stream->duration > 0) {
|
||||
const double start = stream->start_time == AV_NOPTS_VALUE ? origin :
|
||||
stream->start_time * av_q2d(stream->time_base);
|
||||
return start - origin + stream->duration * av_q2d(stream->time_base);
|
||||
}
|
||||
const auto *context = track.format;
|
||||
if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) return NAN;
|
||||
const double start = context->start_time == AV_NOPTS_VALUE ? origin :
|
||||
static_cast<double>(context->start_time) / AV_TIME_BASE;
|
||||
return start - origin + static_cast<double>(context->duration) / AV_TIME_BASE;
|
||||
}
|
||||
|
||||
bool beyondAudio(double target) const {
|
||||
const double end = audioEnd();
|
||||
return std::isfinite(end) && target + sampleTime * 0.5 >= end;
|
||||
}
|
||||
|
||||
void markAudioTail(double target) {
|
||||
if (!network || !beyondAudio(target)) return;
|
||||
tracks[0].sourceEnded = true;
|
||||
if (tracks[0].input == tracks[1].input && tracks[0].input)
|
||||
tracks[0].input->discardAudio(true);
|
||||
}
|
||||
|
||||
void seek(double seconds, bool resume) {
|
||||
if (!format) return;
|
||||
if (!format || !canSeek) return;
|
||||
const double target = duration > 0 ? std::clamp(seconds, 0.0, duration) : std::max(0.0, seconds);
|
||||
const long double absolute = std::round((static_cast<long double>(target) + origin) * AV_TIME_BASE);
|
||||
if (absolute < -0x1p63L || absolute >= 0x1p63L)
|
||||
throw std::runtime_error("Seek target is outside the supported timestamp range");
|
||||
output.pause();
|
||||
const int64_t timestamp = static_cast<int64_t>(absolute);
|
||||
if (network) {
|
||||
for (auto &input : inputs) {
|
||||
if (!input) continue;
|
||||
input->discardAudio(false);
|
||||
if (input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target))
|
||||
input->finish();
|
||||
else
|
||||
input->seek(-1, timestamp);
|
||||
}
|
||||
} else {
|
||||
check(avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(),
|
||||
timestamp, timestamp, 0), "Seeking media");
|
||||
}
|
||||
resetTimeline(target);
|
||||
markAudioTail(target);
|
||||
buffering = network;
|
||||
status = PLAYER_PAUSED;
|
||||
if (resume) start();
|
||||
}
|
||||
|
||||
void seekVideo(int64_t timestamp, double target) {
|
||||
output.pause();
|
||||
if (network) {
|
||||
auto &videoTrack = tracks[1];
|
||||
videoTrack.input->seek(videoTrack.index, timestamp);
|
||||
if (tracks[0].input && tracks[0].input != videoTrack.input) {
|
||||
const long double audioTime = std::round((static_cast<long double>(target) + origin) * AV_TIME_BASE);
|
||||
if (audioTime < -0x1p63L || audioTime >= 0x1p63L)
|
||||
throw std::runtime_error("Audio seek target is outside the supported timestamp range");
|
||||
if (beyondAudio(target)) tracks[0].input->finish();
|
||||
else tracks[0].input->seek(-1, static_cast<int64_t>(audioTime));
|
||||
}
|
||||
} else {
|
||||
check(av_seek_frame(format, tracks[1].index, timestamp, AVSEEK_FLAG_BACKWARD),
|
||||
"Seeking video keyframe");
|
||||
}
|
||||
// Decoder preroll must not blank the last presented frame between steps.
|
||||
resetTimeline(target, false);
|
||||
markAudioTail(target);
|
||||
buffering = network;
|
||||
status = PLAYER_PAUSED;
|
||||
}
|
||||
|
||||
@@ -483,9 +722,11 @@ struct Player {
|
||||
}
|
||||
|
||||
void step(int direction) {
|
||||
if (!format || !tracks[1].codec) return;
|
||||
if (!format || !canSeek || !tracks[1].codec) return;
|
||||
pause();
|
||||
status = PLAYER_PAUSED;
|
||||
// Combined inputs must not block video stepping behind an unused audio packet queue.
|
||||
if (network && tracks[1].input) tracks[1].input->discardAudio(true);
|
||||
// A seek followed immediately by a queued step must first establish its preview.
|
||||
while (!presented.frame) {
|
||||
auto frame = nextSteppingFrame();
|
||||
@@ -505,7 +746,7 @@ struct Player {
|
||||
const uint64_t referenceOccurrence = selected.timestampOccurrence;
|
||||
if (reference == AV_NOPTS_VALUE)
|
||||
throw std::runtime_error("Frame stepping requires video presentation timestamps");
|
||||
const AVStream *stream = format->streams[tracks[1].index];
|
||||
const AVStream *stream = tracks[1].stream();
|
||||
const long double ticksPerSecond = 1.0L / av_q2d(stream->time_base);
|
||||
const long double originTimestamp = std::clamp(std::floor(origin * ticksPerSecond),
|
||||
static_cast<long double>(std::numeric_limits<int64_t>::min() + 1),
|
||||
@@ -559,6 +800,7 @@ struct Player {
|
||||
position = selected.pts;
|
||||
steppingAnchor = target;
|
||||
frameStepping = true;
|
||||
buffering = false;
|
||||
presentVideo(std::move(selected));
|
||||
}
|
||||
|
||||
@@ -566,7 +808,7 @@ struct Player {
|
||||
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
activeEpoch = command.epoch;
|
||||
switch (command.action) {
|
||||
case Action::Open: open(command.path); break;
|
||||
case Action::Open: open(command); break;
|
||||
case Action::Play: start(); break;
|
||||
case Action::Pause: pause(); break;
|
||||
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
||||
@@ -578,8 +820,11 @@ struct Player {
|
||||
|
||||
double framePts(Track &track, const AVFrame *frame) {
|
||||
const int64_t timestamp = presentationTimestamp(frame);
|
||||
if (timestamp == AV_NOPTS_VALUE) return track.nextPts;
|
||||
return timestamp * av_q2d(format->streams[track.index]->time_base) - origin;
|
||||
if (timestamp == AV_NOPTS_VALUE) {
|
||||
if (network) throw std::runtime_error("The network stream has no media presentation timestamps");
|
||||
return track.nextPts;
|
||||
}
|
||||
return timestamp * av_q2d(track.stream()->time_base) - origin;
|
||||
}
|
||||
|
||||
void convertAudio() {
|
||||
@@ -604,7 +849,7 @@ struct Player {
|
||||
throw std::runtime_error("Audio format changes within a track are not supported");
|
||||
}
|
||||
const double timestamp = framePts(track, decoded);
|
||||
const double tick = av_q2d(format->streams[track.index]->time_base);
|
||||
const double tick = av_q2d(track.stream()->time_base);
|
||||
const bool contiguous = continuing && std::abs(timestamp - track.nextPts) <= tick + 1.0 / rate;
|
||||
// Quantized packet PTS and rounded resampler delay must not insert/drop PCM samples.
|
||||
const int64_t delayBase = static_cast<int64_t>(rate) * sampleRate;
|
||||
@@ -701,7 +946,7 @@ struct Player {
|
||||
throw std::runtime_error("Frame stepping requires video presentation timestamps");
|
||||
const double pts = framePts(track, decoded);
|
||||
const double frameDuration = decoded->duration > 0 ?
|
||||
decoded->duration * av_q2d(format->streams[track.index]->time_base) : videoFrameTime;
|
||||
decoded->duration * av_q2d(track.stream()->time_base) : videoFrameTime;
|
||||
track.nextPts = pts + frameDuration;
|
||||
if (decoded->flags & AV_FRAME_FLAG_DISCARD) return;
|
||||
if (timestamp != track.countedTimestamp) {
|
||||
@@ -725,8 +970,9 @@ struct Player {
|
||||
if (bytes > videoByteLimit)
|
||||
throw std::runtime_error("A decoded video frame exceeds the 128 MiB buffer limit");
|
||||
videoEnd = std::max(videoEnd, pts + frameDuration);
|
||||
// ponytail: extreme interleaving can starve audio behind a full video queue.
|
||||
// Keep nearest frames plus the newest; packet spooling is the upgrade if that matters.
|
||||
if (network && videoBytes + bytes > videoByteLimit)
|
||||
throw std::runtime_error("Decoded video exceeds the 128 MiB buffer limit");
|
||||
// Local, extremely interleaved files retain nearest frames when audio needs to catch up.
|
||||
while (!video.empty() && (video.size() >= videoFrameLimit || videoBytes + bytes > videoByteLimit)) {
|
||||
videoBytes -= video.back().bytes;
|
||||
video.pop_back();
|
||||
@@ -741,72 +987,86 @@ struct Player {
|
||||
}
|
||||
|
||||
bool decodeOne(bool stepping = false) {
|
||||
if (!stepping && pcmOffset < pcmFrames) return false;
|
||||
if (receiving >= 0) {
|
||||
if (stepping && receiving == 0) {
|
||||
receiving = -1;
|
||||
return true;
|
||||
}
|
||||
auto &track = tracks[receiving];
|
||||
if (receiving == 1 && videoFull() &&
|
||||
(!tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING))
|
||||
return false;
|
||||
for (const auto &input : inputs)
|
||||
if (input) check(input->error(), "Reading network media");
|
||||
for (int offset = 0; offset < 2; ++offset) {
|
||||
const int slot = (nextDecodeSlot + offset) % 2;
|
||||
auto &track = tracks[slot];
|
||||
if (!track.codec || track.drained || (stepping && slot == 0)) continue;
|
||||
if (slot == 0 && pcmOffset < pcmFrames) continue;
|
||||
if (slot == 1 && videoFull() &&
|
||||
(network || !tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING)) continue;
|
||||
if (track.receiving) {
|
||||
const int result = avcodec_receive_frame(track.codec, decoded);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
receiving = -1;
|
||||
return true;
|
||||
}
|
||||
if (result == AVERROR_EOF) {
|
||||
track.receiving = false;
|
||||
} else if (result == AVERROR_EOF) {
|
||||
track.drained = true;
|
||||
receiving = -1;
|
||||
return true;
|
||||
}
|
||||
track.receiving = false;
|
||||
} else {
|
||||
check(result, "Decoding media");
|
||||
if (receiving == 0) convertAudio(); else queueVideo(stepping);
|
||||
if (slot == 0) convertAudio(); else queueVideo(stepping);
|
||||
av_frame_unref(decoded);
|
||||
}
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
if (packetPending) {
|
||||
if (stepping && packet->stream_index == tracks[0].index) {
|
||||
av_packet_unref(packet);
|
||||
packetPending = false;
|
||||
return true;
|
||||
}
|
||||
const int slot = packet->stream_index == tracks[0].index ? 0 : 1;
|
||||
const int result = avcodec_send_packet(tracks[slot].codec, packet);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
receiving = slot;
|
||||
return true;
|
||||
}
|
||||
if (track.packetPending) {
|
||||
const int result = avcodec_send_packet(track.codec, track.packet);
|
||||
if (result != AVERROR(EAGAIN)) {
|
||||
check(result, "Sending media packet to decoder");
|
||||
av_packet_unref(packet);
|
||||
packetPending = false;
|
||||
receiving = slot;
|
||||
av_packet_unref(track.packet);
|
||||
track.packetPending = false;
|
||||
}
|
||||
track.receiving = true;
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
if (demuxEnded) {
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
auto &track = tracks[slot];
|
||||
if (!track.codec || track.drainSent || (stepping && slot == 0)) continue;
|
||||
if (track.input && !track.sourceEnded) {
|
||||
const int result = track.input->take(slot, track.packet);
|
||||
if (result == AVERROR_EOF) track.sourceEnded = true;
|
||||
else if (result == 1) track.packetPending = true;
|
||||
else check(result, "Reading network media packet");
|
||||
if (result != 0) {
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (track.sourceEnded && !track.drainSent) {
|
||||
const int result = avcodec_send_packet(track.codec, nullptr);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
receiving = slot;
|
||||
return true;
|
||||
}
|
||||
if (result != AVERROR(EAGAIN)) {
|
||||
if (result != AVERROR_EOF) check(result, "Draining decoder");
|
||||
track.drainSent = true;
|
||||
receiving = slot;
|
||||
}
|
||||
track.receiving = true;
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
if (!stepping && (!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
|
||||
}
|
||||
if (!stepping && pcmOffset >= pcmFrames &&
|
||||
(!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
|
||||
drainResampler();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (network || demuxEnded) return false;
|
||||
// The local path keeps one reused demux packet and no reader or packet-queue allocation.
|
||||
if (packetPending) {
|
||||
const int slot = packet->stream_index == tracks[0].index ? 0 : 1;
|
||||
if (stepping && slot == 0) {
|
||||
av_packet_unref(packet);
|
||||
} else {
|
||||
auto &track = tracks[slot];
|
||||
if (track.packetPending) return false;
|
||||
av_packet_move_ref(track.packet, packet);
|
||||
track.packetPending = true;
|
||||
}
|
||||
packetPending = false;
|
||||
return true;
|
||||
}
|
||||
const int result = av_read_frame(format, packet);
|
||||
if (result == AVERROR_EOF) {
|
||||
demuxEnded = true;
|
||||
for (auto &track : tracks) track.sourceEnded = true;
|
||||
return true;
|
||||
}
|
||||
if (result == AVERROR(EAGAIN)) return false;
|
||||
@@ -830,9 +1090,54 @@ struct Player {
|
||||
if (latest.frame) presentVideo(std::move(latest));
|
||||
}
|
||||
|
||||
bool audioDrained() const {
|
||||
return (!tracks[0].codec || tracks[0].drained) && resamplerDrained && pcmOffset >= pcmFrames;
|
||||
}
|
||||
|
||||
void updateBuffering() {
|
||||
if (!network || status != PLAYER_PLAYING) return;
|
||||
const bool seekPending = networkSeeksPending();
|
||||
const bool videoRemaining = tracks[1].codec &&
|
||||
(!tracks[1].drained || audioWriteEnd + sampleTime < videoEnd);
|
||||
const bool audioEmpty = tracks[0].codec && output.queued() == 0 &&
|
||||
(!audioDrained() || videoRemaining);
|
||||
const bool videoEmpty = tracks[1].codec && !tracks[1].drained && video.empty() &&
|
||||
(!presented.frame || position + 0.002 >= presented.end);
|
||||
if (!buffering && (seekPending || audioEmpty || videoEmpty)) {
|
||||
output.pause();
|
||||
if (tracks[0].codec) {
|
||||
const double frozen = output.clock();
|
||||
if (std::isfinite(frozen)) position = std::max(seekTarget, frozen);
|
||||
} else {
|
||||
position = std::min(position, std::max(seekTarget, videoEnd));
|
||||
}
|
||||
buffering = true;
|
||||
}
|
||||
const bool audioReady = !tracks[0].codec || output.queued() >= 4800 ||
|
||||
(audioDrained() && output.queued() > 0) ||
|
||||
(audioDrained() && !videoRemaining);
|
||||
const bool videoReady = !tracks[1].codec || tracks[1].drained || !video.empty() ||
|
||||
(presented.frame && presented.end > position + 0.002);
|
||||
if (buffering && !seekPending && audioReady && videoReady) {
|
||||
if (tracks[0].codec) {
|
||||
std::string deviceError;
|
||||
if (!output.start(deviceError))
|
||||
throw std::runtime_error(deviceError.empty() ? "Cannot start audio output" : deviceError);
|
||||
}
|
||||
steadyPosition = position;
|
||||
steadyAnchor = Clock::now();
|
||||
buffering = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool tick() {
|
||||
if (!format || interrupted() || status == PLAYER_ENDED || status == PLAYER_ERROR) return false;
|
||||
if (frameStepping) return false;
|
||||
for (const auto &input : inputs)
|
||||
if (input) check(input->error(), "Reading network media");
|
||||
if (frameStepping) {
|
||||
if (network) publish(); // Reader seek completion must still acknowledge a paused step.
|
||||
return false;
|
||||
}
|
||||
bool progressed = pumpAudio();
|
||||
if (status == PLAYER_PAUSED) {
|
||||
if (!previewShown && !video.empty()) {
|
||||
@@ -842,25 +1147,31 @@ struct Player {
|
||||
presentVideo(std::move(frame));
|
||||
progressed = true;
|
||||
}
|
||||
const bool needPreview = tracks[1].codec && !previewShown;
|
||||
const bool needAudio = tracks[0].codec && output.queued() < 4800;
|
||||
const bool needPreview = tracks[1].codec && !previewShown && !tracks[1].drained;
|
||||
const bool needAudio = tracks[0].codec && output.queued() < 4800 && !audioDrained();
|
||||
if (needPreview || needAudio) progressed = decodeOne() || progressed;
|
||||
if (network) {
|
||||
buffering = networkSeeksPending() || needPreview ||
|
||||
(needAudio && output.queued() == 0);
|
||||
publish();
|
||||
}
|
||||
return progressed;
|
||||
}
|
||||
position = clockPosition();
|
||||
renderVideo(position);
|
||||
progressed = decodeOne() || progressed;
|
||||
if (demuxEnded && (!tracks[0].codec || tracks[0].drained) &&
|
||||
resamplerDrained && pcmOffset >= pcmFrames && tracks[0].codec)
|
||||
// Audio ending first is not a global demux EOF: keep the audio-master clock through video's tail.
|
||||
if (tracks[0].codec && audioDrained())
|
||||
progressed = writeSilence(videoEnd) || progressed;
|
||||
const bool drained = demuxEnded && resamplerDrained && pcmOffset >= pcmFrames &&
|
||||
(!tracks[0].codec || tracks[0].drained) && (!tracks[1].codec || tracks[1].drained);
|
||||
updateBuffering();
|
||||
const bool drained = audioDrained() && (!tracks[1].codec || tracks[1].drained);
|
||||
if (drained && video.empty()) {
|
||||
const double end = std::max(audioWriteEnd, videoEnd);
|
||||
if (position + sampleTime >= end && (!tracks[0].codec || output.queued() == 0)) {
|
||||
output.pause();
|
||||
position = end;
|
||||
if (duration <= 0) duration = end;
|
||||
buffering = false;
|
||||
status = PLAYER_ENDED;
|
||||
}
|
||||
}
|
||||
@@ -946,6 +1257,22 @@ bool player_open(Player *player, const char *path) {
|
||||
if (!path || !*path) return false;
|
||||
return submit(player, [=] { return player->enqueue({Action::Open, path}); });
|
||||
}
|
||||
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
||||
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
|
||||
!validNetworkURL(source->video_url) ||
|
||||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
|
||||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
|
||||
return submit(player, [=] {
|
||||
Command command{Action::Open, source->video_url};
|
||||
command.audioURL = source->audio_url ? source->audio_url : "";
|
||||
command.videoHeaders = source->video_headers ? source->video_headers : "";
|
||||
command.audioHeaders = source->audio_headers ? source->audio_headers : "";
|
||||
command.sourceDuration = source->duration;
|
||||
command.network = true;
|
||||
command.live = source->is_live;
|
||||
return player->enqueue(std::move(command));
|
||||
});
|
||||
}
|
||||
bool player_play(Player *player) {
|
||||
return submit(player, [=] { return player->enqueue({Action::Play, {}}); });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ typedef struct PlayerState {
|
||||
double duration;
|
||||
bool has_audio;
|
||||
bool has_video;
|
||||
// False for live/non-seekable inputs; reflects both inputs for separate streams.
|
||||
bool can_seek;
|
||||
// Network playback is waiting for media; the authoritative clock remains stalled.
|
||||
bool buffering;
|
||||
// True until all accepted seeks/frame steps on this timeline finish.
|
||||
bool seek_pending;
|
||||
// Display width / height, including sample aspect ratio; zero without video.
|
||||
@@ -28,7 +32,19 @@ typedef struct PlayerState {
|
||||
// Playback commands are asynchronous. False means invalid input or a full command queue.
|
||||
// Paths are copied before returning. All other calls require a live handle.
|
||||
Player *player_create(void);
|
||||
typedef struct PlayerNetworkSource {
|
||||
// Primary video or combined audio/video HTTP(S) URL; audio_url is optional.
|
||||
const char *video_url;
|
||||
const char *audio_url;
|
||||
// Optional HTTP headers encoded as "Name: value\r\n"; copied with the URLs.
|
||||
const char *video_headers;
|
||||
const char *audio_headers;
|
||||
double duration;
|
||||
bool is_live;
|
||||
} PlayerNetworkSource;
|
||||
bool player_open(Player *player, const char *path);
|
||||
// Streams directly; never downloads or muxes the entire media file before opening.
|
||||
bool player_open_network(Player *player, const PlayerNetworkSource *source);
|
||||
bool player_play(Player *player);
|
||||
bool player_pause(Player *player);
|
||||
bool player_seek(Player *player, double seconds);
|
||||
|
||||
Reference in New Issue
Block a user