Compare commits

..

4 Commits

14 changed files with 1126 additions and 600 deletions
+4 -1
View File
@@ -7,6 +7,9 @@ Terebi plays local media, YouTube videos and resolved Miruro episodes. These ter
**Playback transition**: **Playback transition**:
A requested change of media, playback state, position, or quality that may still be awaiting completion. Acceptance of a request does not mean playback has reached the requested state. A requested change of media, playback state, position, or quality that may still be awaiting completion. Acceptance of a request does not mean playback has reached the requested state.
**Cast playback transition**:
A playback transition that transfers playback between this Mac and a Cast receiver. Connection alone does not transfer playback; the destination must acknowledge the handoff, and interruption must preserve the latest playback intent and acknowledged position.
**Playback intent**: **Playback intent**:
The user's latest requested playing or paused state, which can differ from the state playback has acknowledged. The user's latest requested playing or paused state, which can differ from the state playback has acknowledged.
@@ -14,7 +17,7 @@ The user's latest requested playing or paused state, which can differ from the s
The playback position confirmed by the player after any pending seek or restoration has completed. A seek target or slider preview is not an acknowledged position. The playback position confirmed by the player after any pending seek or restoration has completed. A seek target or slider preview is not an acknowledged position.
**Restoration**: **Restoration**:
Resuming a video's saved position, or preserving position and playing or paused state while changing its quality. Resuming a video's saved position, or preserving position and playing or paused state while changing its quality or returning from a Cast receiver.
**Media identity**: **Media identity**:
The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL. For a Miruro episode, this is its opaque catalog media ID and episode number, independent of provider and expiring source URL. The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL. For a Miruro episode, this is its opaque catalog media ID and episode number, independent of provider and expiring source URL.
+2 -2
View File
@@ -108,8 +108,8 @@ struct AutomaticQualityPlaybackChecks {
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false) !model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
} }
require(model.state.position >= position && model.state.position < position + 3, require(model.state.position >= position,
"automatic quality reopening preserves current playback progress") "automatic quality reopening does not reset acknowledged progress")
polled(model) polled(model)
clock.now = 2063 clock.now = 2063
until(model, "sustained health automatically restores actual 1080p media") { until(model, "sustained health automatically restores actual 1080p media") {
+198 -2
View File
@@ -43,7 +43,7 @@ static void writeFile(const std::string &path, const std::vector<uint8_t> &bytes
std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast<const char *>(bytes.data()), bytes.size()); assert(file.good()); std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast<const char *>(bytes.data()), bytes.size()); assert(file.good());
} }
// Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures. // Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures.
static void fixture(const std::string &path, int seconds = 90, int width = 96, int height = 64, int bitrate = 300000) { static void fixture(const std::string &path, int seconds = 90, int width = 96, int height = 64, int bitrate = 300000, bool compatible = false) {
AVFormatContext *format = nullptr; ok(avformat_alloc_output_context2(&format, nullptr, "mpegts", path.c_str())); AVFormatContext *format = nullptr; ok(avformat_alloc_output_context2(&format, nullptr, "mpegts", path.c_str()));
AVCodecContext *video = avcodec_alloc_context3(avcodec_find_encoder_by_name("h264_videotoolbox")); AVCodecContext *video = avcodec_alloc_context3(avcodec_find_encoder_by_name("h264_videotoolbox"));
AVCodecContext *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio); AVCodecContext *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio);
@@ -51,6 +51,10 @@ static void fixture(const std::string &path, int seconds = 90, int width = 96, i
video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = bitrate; video->gop_size = 70; video->max_b_frames = 0; video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = bitrate; video->gop_size = 70; video->max_b_frames = 0;
video->sample_aspect_ratio = {4, 3}; video->sample_aspect_ratio = {4, 3};
av_opt_set(video->priv_data, "allow_sw", "1", 0); av_opt_set(video->priv_data, "realtime", "1", 0); av_opt_set(video->priv_data, "allow_sw", "1", 0); av_opt_set(video->priv_data, "realtime", "1", 0);
if (compatible) {
ok(av_opt_set(video->priv_data, "profile", "main", 0));
ok(av_opt_set(video->priv_data, "level", "4.1", 0));
}
audio->sample_rate = 48000; audio->sample_fmt = AV_SAMPLE_FMT_FLTP; audio->time_base = {1, 48000}; audio->bit_rate = 128000; audio->sample_rate = 48000; audio->sample_fmt = AV_SAMPLE_FMT_FLTP; audio->time_base = {1, 48000}; audio->bit_rate = 128000;
av_channel_layout_default(&audio->ch_layout, 2); av_channel_layout_default(&audio->ch_layout, 2);
ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr)); ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr));
@@ -484,14 +488,206 @@ static void hlsWork(Upstream &upstream, const std::string &segment) {
} }
assert(upstream.foreignRequests == 0); assert(upstream.foreignRequests == 0);
} }
struct CompressedSample {
int64_t dts;
std::vector<uint8_t> bytes;
bool operator==(const CompressedSample &) const = default;
};
using Samples = std::map<std::pair<int, int64_t>, CompressedSample>;
// Compare actual AAC payloads and H.264 slices, excluding container-inserted
// AUD/parameter sets. Also decode each TS independently with strict errors.
static Samples samples(const std::string &path, double origin = NAN) {
AVFormatContext *format = nullptr;
ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr)); ok(avformat_find_stream_info(format, nullptr));
if (std::isnan(origin)) origin = double(format->start_time) / AV_TIME_BASE;
Samples result;
std::map<int, AVCodecContext *> decoders;
std::map<int, int64_t> lastDTS;
std::map<int, int> decoded;
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc(); assert(packet && frame);
while (av_read_frame(format, packet) >= 0) {
auto *stream = format->streams[packet->stream_index];
const bool video = stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO;
assert(stream->codecpar->codec_id == (video ? AV_CODEC_ID_H264 : AV_CODEC_ID_AAC));
assert(packet->pts != AV_NOPTS_VALUE && packet->dts != AV_NOPTS_VALUE);
const int64_t pts = std::llround((packet->pts * av_q2d(stream->time_base) - origin) * 90000);
const int64_t dts = std::llround((packet->dts * av_q2d(stream->time_base) - origin) * 90000);
if (lastDTS.contains(video)) assert(dts > lastDTS[video]);
lastDTS[video] = dts;
std::vector<uint8_t> payload;
if (video) {
bool idr = false;
for (int at = 0; at + 3 < packet->size;) {
if (packet->data[at] || packet->data[at + 1] || packet->data[at + 2] != 1) { ++at; continue; }
const int begin = at + 3, type = packet->data[begin] & 31;
at = begin + 1;
while (at + 3 < packet->size &&
(packet->data[at] || packet->data[at + 1] || packet->data[at + 2] != 1)) ++at;
int end = at + 3 < packet->size ? at : packet->size;
while (end > begin && packet->data[end - 1] == 0) --end;
if (type == 1 || type == 5) payload.insert(payload.end(), packet->data + begin, packet->data + end);
idr = idr || type == 5;
}
if (!decoders.contains(video)) assert((packet->flags & AV_PKT_FLAG_KEY) && idr);
assert(!payload.empty());
} else payload.assign(packet->data, packet->data + packet->size);
assert(result.emplace(std::pair{int(video), pts}, CompressedSample{dts, std::move(payload)}).second);
if (!decoders.contains(video)) {
auto *decoder = avcodec_alloc_context3(avcodec_find_decoder(stream->codecpar->codec_id)); assert(decoder);
ok(avcodec_parameters_to_context(decoder, stream->codecpar));
decoder->err_recognition = AV_EF_EXPLODE;
ok(avcodec_open2(decoder, decoder->codec, nullptr)); decoders[video] = decoder;
}
ok(avcodec_send_packet(decoders[video], packet));
while (avcodec_receive_frame(decoders[video], frame) >= 0) { ++decoded[video]; av_frame_unref(frame); }
av_packet_unref(packet);
}
for (auto &[type, decoder] : decoders) {
ok(avcodec_send_packet(decoder, nullptr));
while (avcodec_receive_frame(decoder, frame) >= 0) { ++decoded[type]; av_frame_unref(frame); }
assert(decoded[type] == int(std::count_if(result.begin(), result.end(), [&](const auto &row) { return row.first.first == type; })));
avcodec_free_context(&decoder);
}
assert(decoded[0] > 0 && decoded[1] > 0);
av_packet_free(&packet); av_frame_free(&frame); avformat_close_input(&format);
return result;
}
// Indexed, seven-second GOPs used to transcode at every six-second boundary.
static void remuxWork(const std::filesystem::path &directory) {
const auto source = (directory / "remux.ts").string(), indexed = (directory / "remux.mp4").string();
fixture(source, 28, 1280, 720, 3000000, true);
// Keep audio with the indexed video so the check covers both packet lanes.
const char *arguments[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", source.c_str(), "-map", "0", "-c", "copy", "-movflags", "+faststart", indexed.c_str(), nullptr};
pid_t child = 0;
assert(posix_spawnp(&child, arguments[0], nullptr, nullptr, const_cast<char *const *>(arguments), environ) == 0);
int status = 0; assert(waitpid(child, &status, 0) == child);
assert(WIFEXITED(status) && WEXITSTATUS(status) == 0);
std::string url; double duration = 0;
auto began = std::chrono::steady_clock::now();
auto *media = start({indexed.c_str(), "", nullptr, nullptr, true, false, false, 0}, url, duration);
const auto prepared = std::chrono::steady_clock::now();
auto manifest = request(url); assert(manifest.status == 200);
std::string text(manifest.bytes.begin(), manifest.bytes.end()), base = url.substr(0, url.rfind('/') + 1);
std::vector<double> boundaries{0};
for (size_t at = 0; (at = text.find("#EXTINF:", at)) != std::string::npos; ++at)
boundaries.push_back(boundaries.back() + std::stod(text.substr(at + 8)));
size_t bytes = 0;
std::vector<HTTP> outputs;
for (size_t id = 0; id + 1 < boundaries.size(); ++id) {
auto response = request(base + "segment/" + std::to_string(id) + ".ts");
assert(response.status == 200);
bytes += response.bytes.size(); outputs.push_back(std::move(response));
}
const auto finished = std::chrono::steady_clock::now();
double longest = 0;
for (size_t i = 1; i < boundaries.size(); ++i) longest = std::max(longest, boundaries[i] - boundaries[i - 1]);
std::cout << "remux-work prepare_ms=" << std::chrono::duration<double, std::milli>(prepared - began).count()
<< " generate_ms=" << std::chrono::duration<double, std::milli>(finished - prepared).count()
<< " segments=" << outputs.size() << " bytes=" << bytes
<< " max_seconds=" << longest << std::endl;
assert(request(base + "segment/" + std::to_string(outputs.size()) + ".ts").status == 404);
assert(boundaries.size() == 5);
for (size_t i = 0; i < boundaries.size(); ++i) assert(std::abs(boundaries[i] - i * 7.0) < .1);
assert(text.find("#EXT-X-TARGETDURATION:8\n") != std::string::npos);
Samples delivered;
for (size_t id = 0; id < outputs.size(); ++id) {
const auto path = (directory / "output.ts").string(); writeFile(path, outputs[id].bytes);
auto rows = samples(path, 1);
for (auto &[key, value] : rows) {
assert(double(key.second) / 90000 >= boundaries[id] - .00002);
assert(double(key.second) / 90000 < boundaries[id + 1] + .00002);
assert(delivered.emplace(key, std::move(value)).second);
}
}
assert(delivered == samples(source));
cast_media_destroy(media);
const auto video = (directory / "indexed-video.mp4").string(), audio = (directory / "indexed-audio.m4a").string();
splitFixture(source, video, AVMEDIA_TYPE_VIDEO); splitFixture(source, audio, AVMEDIA_TYPE_AUDIO);
Upstream upstream(readFile(indexed), wav());
upstream.resources["/video.mp4"] = readFile(video); upstream.resources["/audio.m4a"] = readFile(audio);
upstream.start();
for (bool split : {false, true}) {
const auto videoURL = upstream.url(split ? "/video.mp4" : "/source.ts"), audioURL = upstream.url("/audio.m4a");
media = start({videoURL.c_str(), "X-Video: video-secret\r\n", split ? audioURL.c_str() : nullptr,
split ? "X-Audio: audio-secret\r\n" : nullptr, false, false, false, 0}, url, duration);
base = url.substr(0, url.rfind('/') + 1);
manifest = request(url); assert(manifest.status == 200);
text.assign(manifest.bytes.begin(), manifest.bytes.end());
std::vector<double> networkBoundaries{0};
for (size_t at = 0; (at = text.find("#EXTINF:", at)) != std::string::npos; ++at)
networkBoundaries.push_back(networkBoundaries.back() + std::stod(text.substr(at + 8)));
const int64_t videoOrigin = split ? delivered.lower_bound({1, INT64_MIN})->first.second : 0;
for (int id : {3, 0, 2, 1}) {
auto response = request(base + "segment/" + std::to_string(id) + ".ts");
assert(response.status == 200);
const auto path = (directory / "network-output.ts").string(); writeFile(path, response.bytes);
auto rows = samples(path, 1);
Samples expected;
for (const auto &[key, value] : delivered) {
const int64_t shift = key.first ? videoOrigin : 0;
const auto adjusted = std::pair{key.first, key.second - shift};
if (adjusted.second / 90000.0 >= networkBoundaries[id] - .00002 &&
adjusted.second / 90000.0 < networkBoundaries[id + 1] - .00002)
expected.emplace(adjusted, CompressedSample{value.dts - shift, value.bytes});
}
if (rows != expected)
std::cerr << "remux-work split=" << split << " id=" << id << " actual=" << rows.size()
<< " expected=" << expected.size() << std::endl;
assert(rows == expected);
assert(request(base + "segment/" + std::to_string(id) + ".ts").bytes == response.bytes);
}
assert(upstream.foreignRequests == 0);
cast_media_destroy(media);
}
// AAC transcoding must stay continuous across non-six-second boundaries.
const auto videoURL = upstream.url("/video.mp4"), audioURL = upstream.url("/audio.wav");
media = start({videoURL.c_str(), "X-Video: video-secret\r\n", audioURL.c_str(), "X-Audio: audio-secret\r\n",
false, false, false, 0}, url, duration);
base = url.substr(0, url.rfind('/') + 1);
auto before = audioSpan(request(base + "segment/0.ts"), (directory / "audio-output.ts").string());
auto after = audioSpan(request(base + "segment/1.ts"), (directory / "audio-output.ts").string());
assert(std::abs(after.first - before.second) < 1.0 / 48000);
cast_media_destroy(media);
// Reordered video and GOPs beyond the duration ceiling keep the fixed-grid
// fallback, including random-access independently decodable output.
for (bool reordered : {true, false}) {
const auto fallback = (directory / "fallback.mp4").string();
const char *args[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", source.c_str(),
"-c:v", "libx264", "-profile:v", "main", "-level:v", "4.1",
"-bf", reordered ? "2" : "0", "-g", reordered ? "70" : "140",
"-keyint_min", reordered ? "70" : "140", "-sc_threshold", "0",
"-c:a", "copy", "-movflags", "+faststart", fallback.c_str(), nullptr};
assert(posix_spawnp(&child, args[0], nullptr, nullptr, const_cast<char *const *>(args), environ) == 0);
assert(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0);
media = start({fallback.c_str(), "", nullptr, nullptr, true, false, false, 0}, url, duration);
manifest = request(url); text.assign(manifest.bytes.begin(), manifest.bytes.end());
assert(text.find("#EXT-X-TARGETDURATION:6\n") != std::string::npos);
base = url.substr(0, url.rfind('/') + 1);
auto response = request(base + "segment/3.ts"); assert(response.status == 200);
const auto path = (directory / "fallback-output.ts").string(); writeFile(path, response.bytes);
const auto rows = samples(path, 1);
for (const auto &row : rows) assert(row.first.second >= 18 * 90000 && row.first.second < 24 * 90000);
cast_media_destroy(media);
}
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
bool liveCheck = false, seekCheck = false, hlsCheck = false; bool liveCheck = false, seekCheck = false, hlsCheck = false, remuxCheck = false;
for (int argument = 1; argument < argc; ++argument) { for (int argument = 1; argument < argc; ++argument) {
if (std::string(argv[argument]) == "--live") liveCheck = true; if (std::string(argv[argument]) == "--live") liveCheck = true;
if (std::string(argv[argument]) == "--seek-work") seekCheck = true; if (std::string(argv[argument]) == "--seek-work") seekCheck = true;
if (std::string(argv[argument]) == "--hls-work") hlsCheck = true; if (std::string(argv[argument]) == "--hls-work") hlsCheck = true;
if (std::string(argv[argument]) == "--remux-work") remuxCheck = true;
} }
char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp); char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp);
if (remuxCheck) {
remuxWork(directory);
std::filesystem::remove_all(directory);
std::cout << "cast-media: keyframe-aligned indexed remux passed\n";
return 0;
}
const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string(); const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string();
fixture(video); writeFile(audio, wav()); fixture(video); writeFile(audio, wav());
const auto splitVideo = (directory / "video.mp4").string(), splitAudio = (directory / "audio.m4a").string(); const auto splitVideo = (directory / "video.mp4").string(), splitAudio = (directory / "audio.m4a").string();
+154 -1
View File
@@ -17,6 +17,153 @@ struct CastPlaybackCheck {
require(predicate(), message) require(predicate(), message)
} }
static func interrupted(_ model: PlayerModel, source: URL, port: UInt16,
scenario: String, gates: URL, defaults: UserDefaults) async throws {
func signal(_ name: String) throws {
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
}
func awaitSignal(_ name: String) async throws {
try await until(model, "\(scenario): fixture signals \(name)", permitError: true) {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
}
}
let replacement = gates.appendingPathComponent("replacement.mp4")
try FileManager.default.copyItem(at: source, to: replacement)
let active = scenario.hasSuffix("-active")
let expectedError = scenario == "reject-load" || scenario == "disconnect-active"
model.setVolume(0)
model.open(source)
try await until(model, "interruption source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
require(model.setPlayback(playing: false), "initial local pause accepted")
try await until(model, "initial local pause acknowledged") {
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
}
require(model.seekRelative(9.25 - model.state.position), "initial local position accepted")
try await until(model, "initial local position acknowledged") {
!model.state.seek_pending && abs(model.state.position - 9.25) < 0.15
}
if !active {
require(model.setPlayback(playing: true), "local playback starts before pending handoff")
try await until(model, "local playback acknowledged before handoff") {
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
}
}
let beforeConnect = model.state.position
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
if active {
try await until(model, "active receiver acknowledges handoff") {
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
}
require(abs(model.state.position - 9.25) < 0.15, "active receiver starts at acknowledged local position")
require(model.volume == 1, "active receiver volume is visible before local rollback")
if scenario == "disconnect-active" {
require(model.setPlayback(playing: true), "initial receiver PLAY accepted")
try await until(model, "initial receiver PLAY acknowledged") {
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
}
}
try signal("active")
}
try await awaitSignal("held")
if !active {
require(model.castDevice != nil && model.castConnecting, "held handoff remains pending")
if scenario == "cancel-connect" {
try await until(model, "pending connection does not pause local playback") {
model.state.status == PLAYER_PLAYING && model.state.position > beforeConnect
}
} else {
try await until(model, "pending LOAD follows acknowledged local pause") { model.state.status == PLAYER_PAUSED }
}
}
if scenario == "reject-load" {
try signal("observed")
try await until(model, "LOAD rejection restores original local playback", permitError: true) {
model.castDevice == nil && !model.castConnecting && model.commandError != nil &&
model.state.status == PLAYER_PLAYING && model.canSeek
}
} else {
switch scenario {
case "cancel-connect", "cancel-load":
model.stopCasting()
case "replace-load", "replace-active":
model.open(replacement)
case "close-load", "close-active":
model.closeFile()
case "shutdown-load", "shutdown-active":
model.shutdown()
case "disconnect-active":
require(model.setPlayback(playing: false) && model.setPlayback(playing: true) &&
model.setPlayback(playing: false), "latest unacknowledged receiver PAUSE accepted")
require(model.playbackIntent == false && model.state.status == PLAYER_PLAYING,
"latest intent remains distinct from acknowledged receiver playback")
require(model.seekRelative(10), "receiver seek preview accepted before disconnect")
require(model.state.seek_pending, "receiver seek remains pending before disconnect")
default:
fatalError("Unknown interruption scenario: " + scenario)
}
try signal("interrupted")
}
try await awaitSignal("released")
if scenario.hasPrefix("shutdown-") {
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
"shutdown releases Cast ownership and permanently disables the local player")
require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
"late receiver traffic cannot resume a destroyed player or replace local volume")
print("PASS: " + scenario)
return
}
if scenario == "disconnect-active" {
try await until(model, "disconnect restores latest intent at acknowledged position", permitError: true) {
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.playbackIntent == nil &&
abs(model.state.position - 9.25) < 0.15
}
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 9.25) < 0.15,
"unacknowledged remote seek preview never replaces saved acknowledged position")
} else if scenario.hasPrefix("close-") {
try await until(model, "close acknowledges empty decoder after late receiver traffic") {
model.state.status == PLAYER_EMPTY && model.fileName == nil && model.castDevice == nil && !model.isBusy
}
require(model.playbackIntent == nil, "close does not leave an accidental resume intent")
model.open(replacement)
} else if scenario.hasPrefix("replace-") {
try await until(model, "replacement acknowledges its own local playback after late receiver traffic") {
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
model.fileName == replacement.lastPathComponent && model.state.status == PLAYER_PLAYING && model.canSeek
}
} else {
try await until(model, "cancelled or rejected handoff leaves usable local playback", permitError: expectedError) {
model.castDevice == nil && !model.castConnecting && model.state.status == PLAYER_PLAYING && model.canSeek
}
}
require(model.volume == 0, "every interrupted handoff restores local volume")
if scenario != "disconnect-active" {
// A native acknowledgement is the barrier after released late receiver traffic;
// never sleep and infer that stale callbacks did nothing.
try await until(model, "local decoder is usable after interruption", permitError: expectedError) {
model.canSeek && !model.isBusy
}
require(model.setPlayback(playing: false), "post-interruption local pause accepted")
try await until(model, "post-interruption local pause acknowledged", permitError: expectedError) {
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
}
require(model.seekRelative(4.25 - model.state.position), "post-interruption local seek accepted")
try await until(model, "local seek acknowledges after obsolete receiver replies", permitError: expectedError) {
!model.state.seek_pending && abs(model.state.position - 4.25) < 0.15
}
require(model.castDevice == nil && !model.castConnecting && model.volume == 0,
"obsolete receiver callbacks cannot reclaim playback or volume")
if scenario.hasPrefix("replace-") || scenario.hasPrefix("close-") {
require(model.fileName == replacement.lastPathComponent, "interruption preserves replacement media identity")
}
}
model.closeFile()
try await until(model, "interrupted scenario closes all playback", permitError: expectedError) {
model.state.status == PLAYER_EMPTY && model.castDevice == nil && !model.castConnecting
}
print("PASS: " + scenario)
}
static func main() async throws { static func main() async throws {
_ = NSApplication.shared _ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited) NSApp.setActivationPolicy(.prohibited)
@@ -32,6 +179,11 @@ struct CastPlaybackCheck {
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults) let model = PlayerModel(defaults: defaults)
defer { model.shutdown() } defer { model.shutdown() }
if CommandLine.arguments.count == 5 {
try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3],
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
return
}
model.setVolume(0) model.setVolume(0)
model.open(source) model.open(source)
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek } try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
@@ -48,7 +200,8 @@ struct CastPlaybackCheck {
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting") require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
require(model.setPlayback(playing: true), "remote play accepted") require(model.setPlayback(playing: true), "remote play accepted")
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
require(model.seekRelative(5) && model.seekRelative(10), "rapid remote skips accumulate before acknowledgement") for _ in 0..<64 { require(model.seekRelative(0.125), "rapid remote skips accumulate beyond queue capacity") }
require(model.seekRelative(7), "final remote skip retains accumulated target")
try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 } try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 }
require(model.setPlayback(playing: false), "remote pause accepted") require(model.setPlayback(playing: false), "remote pause accepted")
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
+157 -12
View File
@@ -87,6 +87,11 @@ private final class Receiver {
var rejectCommand = false var rejectCommand = false
var holdSeek = false var holdSeek = false
var holdVolume = false var holdVolume = false
var holdLaunch = false
var holdLoad = false
var holdPlayback = false
private var heldLaunch: Int?
private var heldLoad: [String: Any]?
var ignoreHeartbeat = false var ignoreHeartbeat = false
var verifyRelay = false var verifyRelay = false
var relayVerified = false var relayVerified = false
@@ -138,6 +143,33 @@ private final class Receiver {
} }
func stop() { socket?.cancel(); listener.cancel() } func stop() { socket?.cancel(); listener.cancel() }
func disconnect() { socket?.cancel() }
func releaseLaunch() throws {
guard let requestID = heldLaunch else { return }
heldLaunch = nil
try receiverStatus(requestID)
}
func releaseLoad(reject: Bool = false) async throws {
guard let command = heldLoad else { return }
heldLoad = nil
if reject {
try send(["type": "LOAD_FAILED", "requestId": command["requestId"] as? Int ?? 0], namespace: CastWire.media)
} else {
try await load(command)
}
}
private func load(_ json: [String: Any]) async throws {
media = json["media"] as! [String: Any]
session += 1
playing = json["autoplay"] as? Bool ?? true
position = json["currentTime"] as? Double ?? 0
activeTracks = json["activeTrackIds"] as? [Int] ?? []
if verifyRelay { try await fetchRelay() }
try status(requestID: json["requestId"] as? Int ?? 0)
}
private func receive(_ socket: NWConnection) { private func receive(_ socket: NWConnection) {
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
@@ -219,20 +251,17 @@ private final class Receiver {
case "CLOSE": break case "CLOSE": break
case "LAUNCH": case "LAUNCH":
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration") require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
if holdLaunch { heldLaunch = requestID; return }
if dropLaunch { return } if dropLaunch { return }
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) } if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
else { try receiverStatus(requestID) } else { try receiverStatus(requestID) }
case "LOAD": case "LOAD":
if holdLoad { heldLoad = json; return }
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return } if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
if dropLoad { return } if dropLoad { return }
media = json["media"] as! [String: Any] try await load(json)
session += 1
playing = json["autoplay"] as? Bool ?? true
position = json["currentTime"] as? Double ?? 0
activeTracks = json["activeTrackIds"] as? [Int] ?? []
if verifyRelay { try await fetchRelay() }
try status(requestID: requestID)
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO": case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
if holdPlayback, type == "PLAY" || type == "PAUSE" { return }
if dropCommands { return } if dropCommands { return }
if rejectCommand { if rejectCommand {
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media) try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
@@ -370,6 +399,39 @@ struct CastSessionCheck {
language: "en", label: "English")]) language: "en", label: "English")])
} }
static func seekCoalescing(identity: SecIdentity) async throws {
let peer = try Receiver(identity: identity)
try await peer.start()
defer { peer.stop() }
let clock = CastCheckClock()
let session = CastSession(sleep: { try await clock.sleep($0) })
var status: CastStatus?
var errors: [String] = []
session.onStatus = { status = $0 }
session.onError = { errors.append($0) }
try await session.connect(to: peer.device)
try await session.load(media(), title: "Seek burst", position: 0, playing: false, subtitle: nil)
let before = peer.commands.count
for target in 1...64 { require(session.seek(Double(target)), "Seek burst exceeds ordinary queue capacity") }
require(session.setPlaying(true), "PLAY separates seek runs")
for target in 65...96 { require(session.seek(Double(target)), "Second seek run accepted") }
require(session.setPlaying(false), "PAUSE separates seek runs")
for target in (48...63).reversed() { require(session.seek(Double(target)), "Reverse seek run accepted") }
require(status?.seekPending == true, "Latest queued seek remains unacknowledged")
try await until("Final seek and playback acknowledged") {
status?.position == 48 && status?.seekPending == false &&
status?.playing == false && status?.playbackPending == false
}
let controls = peer.commands.dropFirst(before).filter { ["SEEK", "PLAY", "PAUSE"].contains($0.0) }
let targets = controls.filter { $0.0 == "SEEK" }.compactMap { $0.1["currentTime"] as? Double }
require(targets == [64, 96, 48] || targets == [1, 64, 96, 48],
"Only unsent adjacent seeks are replaced")
require(Array(controls.suffix(5)).map(\.0) == ["SEEK", "PLAY", "SEEK", "PAUSE", "SEEK"],
"Playback controls remain seek ordering barriers")
require(errors.isEmpty && peer.failure == nil, "Superseded requests do not fail or leak queue capacity")
session.disconnect()
}
static func behavior(identity: SecIdentity) async throws { static func behavior(identity: SecIdentity) async throws {
let peer = try Receiver(identity: identity) let peer = try Receiver(identity: identity)
peer.muted = true peer.muted = true
@@ -607,7 +669,7 @@ struct CastSessionCheck {
static func failures(identity: SecIdentity) async throws { static func failures(identity: SecIdentity) async throws {
for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame", for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame",
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue"] { "ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue", "pending-disconnect"] {
let peer = try Receiver(identity: identity) let peer = try Receiver(identity: identity)
try await peer.start() try await peer.start()
let clock = CastCheckClock() let clock = CastCheckClock()
@@ -671,6 +733,15 @@ struct CastSessionCheck {
require(session.setPlaying(true), "Rejected command sent") require(session.setPlaying(true), "Rejected command sent")
try await until("Command rejection reports recoverable error") { !errors.isEmpty } try await until("Command rejection reports recoverable error") { !errors.isEmpty }
require(!disconnected, "Command rejection is not channel failure") require(!disconnected, "Command rejection is not channel failure")
case "pending-disconnect":
peer.dropCommands = true
require(session.setPlaying(true) && session.seek(12), "Playback and seek accepted before disconnect")
try await until("Pending controls reach receiver before disconnect") {
peer.commands.contains { $0.0 == "PLAY" } && peer.commands.contains { $0.0 == "SEEK" }
}
peer.disconnect()
try await until("Terminal failure is reported through disconnect") { disconnected }
require(errors.isEmpty, "Terminal failure does not masquerade as recoverable command rejection")
case "media-error": try peer.status(state: "IDLE", idle: "ERROR") case "media-error": try peer.status(state: "IDLE", idle: "ERROR")
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED") case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED") case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED")
@@ -718,10 +789,81 @@ struct CastSessionCheck {
let load = peer.commands.first { $0.0 == "LOAD" }!.1 let load = peer.commands.first { $0.0 == "LOAD" }!.1
require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff") require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff")
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes") require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
let controls = peer.commands.filter { ["PLAY", "SEEK", "PAUSE", "STOP"].contains($0.0) } }
require(controls.map(\.0) == ["PLAY", "SEEK", "SEEK", "PAUSE", "STOP"], "Integrated controls ordered")
require(controls[1].1["currentTime"] as? Double == 14.25 && static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws {
controls[2].1["currentTime"] as? Double == 24.25, "Rapid skips accumulate from the pending target") for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load",
"replace-active", "close-active", "shutdown-load", "shutdown-active", "disconnect-active"] {
let peer = try Receiver(identity: identity)
let active = scenario.hasSuffix("-active")
peer.holdLaunch = scenario == "cancel-connect"
peer.holdLoad = !active && scenario != "cancel-connect"
peer.verifyRelay = active
try await peer.start()
defer { peer.stop() }
let gates = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-interruption-" + UUID().uuidString)
try FileManager.default.createDirectory(at: gates, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: gates) }
func exists(_ name: String) -> Bool {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
}
func signal(_ name: String) throws {
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = [file, String(peer.port), scenario, gates.path]
try process.run()
defer { if process.isRunning { process.terminate() } }
func awaitStage(_ message: String, _ predicate: () -> Bool) async throws {
try await until("\(scenario): \(message)", attempts: 18000) {
predicate() || !process.isRunning || peer.failure != nil
}
require(peer.failure == nil, peer.failure ?? "Receiver accepts interrupted playback")
require(process.isRunning, "\(scenario): child remains alive until fixture stage")
require(predicate(), "\(scenario): \(message)")
}
if active {
try await awaitStage("remote playback active") { exists("active") }
require(peer.relayVerified, "\(scenario): active receiver fetched real relay bytes")
if scenario == "disconnect-active" {
peer.holdPlayback = true
peer.holdSeek = true
}
} else {
let command = scenario == "cancel-connect" ? "LAUNCH" : "LOAD"
try await awaitStage("handoff reaches held \(command)") { peer.commands.contains { $0.0 == command } }
}
try signal("held")
if scenario == "reject-load" {
try await awaitStage("consumer observes paused pending LOAD") { exists("observed") }
try await peer.releaseLoad(reject: true)
} else {
try await awaitStage("consumer interrupts handoff") { exists("interrupted") }
if scenario == "disconnect-active" {
try await awaitStage("latest playback and seek reach receiver") {
peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.count == 4 &&
peer.commands.contains { $0.0 == "SEEK" }
}
peer.disconnect()
} else if scenario == "cancel-connect" {
try peer.releaseLaunch()
} else if !active {
// A cancelled LOAD can still produce a late receiver acknowledgement.
// Skip relay fetching: the sender has intentionally stopped that relay.
try await peer.releaseLoad()
} else {
try peer.status(state: "PLAYING")
try peer.receiverStatus(level: 0.9)
}
}
try signal("released")
try await until("\(scenario): subprocess completes", attempts: 18000) {
!process.isRunning || peer.failure != nil
}
require(peer.failure == nil, peer.failure ?? "Receiver accepts interrupted playback")
require(!process.isRunning && process.terminationStatus == 0, "\(scenario): consumer-visible restoration succeeds")
}
} }
static func realPeer(_ arguments: [String]) async throws { static func realPeer(_ arguments: [String]) async throws {
@@ -769,8 +911,11 @@ struct CastSessionCheck {
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" { if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3]) try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect") print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
try await interruptedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery")
} else { } else {
try await behavior(identity: identity) try await behavior(identity: identity)
try await seekCoalescing(identity: identity)
try await launchRegressions(identity: identity) try await launchRegressions(identity: identity)
try await volumeRegressions(identity: identity) try await volumeRegressions(identity: identity)
try await failures(identity: identity) try await failures(identity: identity)
+81
View File
@@ -0,0 +1,81 @@
// Include the implementation to inspect pending commands without adding a public test API.
#include "../Sources/Core/player.cpp"
#include <cstdlib>
static void require(bool condition, const char *message) {
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
}
int main(int argc, char **argv) {
Player player;
// Join before submitting anything: the queue exercise has no executing worker,
// media I/O, device clock or scheduling-dependent queue depth.
player.shutdown();
player.stopping.store(false);
auto reset = [&] {
player.commands.clear();
player.state = {};
player.state.status = PLAYER_PAUSED;
player.state.can_seek = true;
player.state.has_video = true;
player.publishedEpoch = player.requestedEpoch.load();
};
reset();
player.state.position = 7;
player.state.buffered_start = 6;
player.state.buffered_end = 9;
constexpr unsigned requests = 100000;
unsigned accepted = 0;
for (unsigned i = 0; i < requests; ++i)
accepted += player_seek(&player, double(i));
std::printf("Native pending seek burst: requested=%u accepted=%u queued=%zu latest=%.0f\n",
requests, accepted, player.commands.size(), player.commands.back().seconds);
if (argc == 2 && std::strcmp(argv[1], "--measure") == 0) return 0;
require(argc == 1, "only --measure is supported");
require(accepted == requests && player.commands.size() == 1 &&
player.commands.back().seconds == requests - 1,
"every superseding seek is accepted and only the latest target remains");
require(player.state.seek_pending && player.state.revision == requests &&
player.state.buffered_start == 0 && player.state.buffered_end == 0 &&
player.state.position == 7 && player.state.status == PLAYER_PAUSED,
"each accepted seek publishes pending and invalidates buffering, not acknowledged position");
reset();
for (std::size_t i = 0; i < commandLimit - 1; ++i)
require(player_pause(&player), "fill queue with command barriers");
require(player_seek(&player, 1) && player_seek(&player, 2),
"latest queued seek can be replaced even at capacity");
require(player.commands.size() == commandLimit && player.commands.back().seconds == 2 &&
!player_play(&player), "coalescing does not relax capacity for other commands");
for (Action barrier : {Action::Play, Action::Pause, Action::Step,
Action::Open, Action::Close, Action::Remote}) {
reset();
require(player_seek(&player, 1), "seek before barrier");
Command command{barrier, {}};
command.direction = 1;
require(player.enqueue(std::move(command)) && player_seek(&player, 2), "seek after barrier");
require(player.commands.size() == 3 && player.commands[0].action == Action::Seek &&
player.commands[0].seconds == 1 && player.commands[1].action == barrier &&
player.commands[2].action == Action::Seek && player.commands[2].seconds == 2,
"no seek replacement crosses another action");
if (barrier == Action::Open || barrier == Action::Close)
require(player.commands[0].epoch != player.commands[2].epoch,
"timeline replacement separates seek epochs");
}
reset();
require(player_seek(&player, 1), "seek before API error boundary");
player.apiError("queue check boundary");
require(player_seek(&player, 2) && player.commands.size() == 2 &&
player.commands[0].epoch != player.commands[1].epoch,
"adjacent seeks from different epochs remain distinct");
reset();
require(player_seek(&player, 1), "first seek queued");
auto executing = std::move(player.commands.front());
player.commands.pop_front(); // The worker removes a seek before executing it.
require(player_seek(&player, 2) && player_seek(&player, 3) &&
executing.seconds == 1 && player.commands.size() == 1 && player.commands.back().seconds == 3,
"replacement never changes a seek already removed for execution");
std::puts("PASS native seek burst/capacity/publication/action barriers/epochs/executing seek");
}
+1 -1
View File
@@ -170,7 +170,7 @@ struct SystemPlaybackChecks {
require(commands.nextTrackCommand.isEnabled, require(commands.nextTrackCommand.isEnabled,
"seekability changes re-enable commands even when playback metadata is unchanged") "seekability changes re-enable commands even when playback metadata is unchanged")
require(model.seekRelative(1), "relative seek accepted") for _ in 0..<64 { require(model.seekRelative(1.0 / 64), "rapid native skips retain accumulated target") }
until("paused seek acknowledged") { until("paused seek acknowledged") {
model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
abs(model.state.position - settledPosition - 1) < 0.05 abs(model.state.position - settledPosition - 1) < 0.05
+9 -2
View File
@@ -27,7 +27,7 @@ CLI := $(APP)/Contents/MacOS/terebi-cli
BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
SIGNING_IDENTITY ?= - SIGNING_IDENTITY ?= -
.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast .PHONY: all release install check-dependencies run check check-seek-queue check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast
all: $(BUILD_DIR)/terebi all: $(BUILD_DIR)/terebi
$(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json $(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json
@@ -105,7 +105,13 @@ run: all
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies $(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast $(BUILD_DIR)/seek-queue-check: Checks/seek-queue.cpp Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@
check-seek-queue: $(BUILD_DIR)/seek-queue-check
$(BUILD_DIR)/seek-queue-check
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies check-fixtures: | check-dependencies
@@ -242,6 +248,7 @@ $(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift $(SWIFT_SOURCES) $(
check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures
$(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-session-check
$(BUILD_DIR)/cast-media-check --live $(BUILD_DIR)/cast-media-check --live
$(BUILD_DIR)/cast-media-check --remux-work
$(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4 $(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
+80 -342
View File
@@ -1,402 +1,140 @@
# terebi # terebi
A personal native macOS player whose playback code is small enough to own and modify. FFmpeg reads and decodes media; our C++ worker owns local playback. SwiftUI owns the controls, Core Audio owns local audio output, and Metal presents local video. Chromecast owns remote playback. A native macOS player for local media, YouTube videos, and resolved Miruro streams, with compact mode, saved playback positions, and Chromecast support.
## Build and run ## Build and run
Requires macOS 15+, Xcode's command-line tools with Swift 6, Python 3.9+, and the build tools `pkg-config`, Meson and Ninja. Native Intel builds also require NASM: Requires macOS 15+, Xcode command-line tools with Swift 6, Python 3.9+, and these build tools:
The first build fetches pinned [TOMLDecoder 0.4.5](https://github.com/dduan/TOMLDecoder/tree/0.4.5) through SwiftPM. It is statically linked into the CLI/player; running the installed app needs no separate TOML runtime. Its MIT notice is included in the app's Resources.
```sh ```sh
brew install pkg-config meson ninja brew install pkg-config meson ninja
# Intel Macs: brew install nasm # Intel Macs also need: brew install nasm
make make
make run make run
``` ```
The developer app is `.build/terebi.app`; release builds produce `.build/release/terebi.app`. Both are self-contained for macOS 15+ on the build machine's CPU architecture. Copy the whole app to another compatible Mac; no Homebrew, FFmpeg installation, Python installation, yt-dlp installation, or shell PATH setup is needed to run it. Build natively on Apple Silicon or Intel; cross-architecture and Rosetta builds are rejected, and these are not universal or cross-platform binaries. The first build downloads pinned dependencies. Build natively on Apple Silicon or Intel; cross-architecture and Rosetta builds are not supported.
`Dependencies.lock.json` pins SHA256-verified FFmpeg 9.0.1, dav1d 1.5.4, liblzma 5.8.4, standalone yt-dlp 2026.08.19 and Deno 2.9.6 inputs. The first build downloads them into ignored `.build/dependencies/<architecture>`; later builds reuse completed preparations and verified downloads. FFmpeg is built as shared LGPL playback libraries, without programs, filters or unrelated autodetected packages. Cast delivery enables only the built-in AAC and VideoToolbox H.264 encoders and MPEG-TS muxer; dav1d supplies software AV1 and liblzma retains compressed-media support. Native HTTPS uses macOS SecureTransport, not an external OpenSSL installation. The repo carries recipes and checksums, not generated runtime binaries. The resulting `.build/terebi.app` is self-contained: copy the whole app to a compatible Mac of the same architecture. No separate FFmpeg, Python, or yt-dlp installation is needed to run it.
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. ### Install
## Release and installation
```sh ```sh
make release # optimized build in .build/release, without -g make install # builds a release and installs under ~/.local
make install # default PREFIX=$HOME/.local
make install PREFIX="/custom/path" # optional installation prefix
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
terebi --help terebi --help
``` ```
Installation copies the complete app to `~/.local/lib/terebi/terebi.app`; `~/.local/bin/terebi` links to its `Contents/MacOS/terebi-cli`. A custom `PREFIX` uses the same `lib/terebi` and `bin` layout. The default needs no sudo. Release artifacts are separate from developer builds, and reinstall replaces only the installed app and obsolete standalone CLI from the previous layout. Close a running player and relaunch after installing an update. Use `make install PREFIX="/custom/path"` to change the prefix, or `make release` for just `.build/release/terebi.app`. Installation places the app in `lib/terebi/terebi.app` and its CLI symlink in `bin/terebi`. Quit running players before updating.
The app contains its CLI, bootstrap, player library, all seven non-system playback dylibs, standalone yt-dlp with Python/EJS, and Deno. Library references are rewritten relative to the app before nested code and the app are signed; signing is ad-hoc by default for personal use, not notarization. Moving the app does not require rebuilding; update any external CLI symlink to its new location. Build/install still require the development tools listed above. Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and source archives are in the app's Resources; public binary redistribution requires a separate dependency/license audit, including the GPLv3+ standalone yt-dlp bundle.
Both the CLI and the lightweight bootstrap preflight the five bundled FFmpeg libraries and their transitive dependencies. Missing, incompatible or broken libraries identify the damaged bundle and request rebuilding/reinstalling the complete app—not installing Homebrew packages. The CLI exits **69**; Finder launches show a native error dialog instead of failing in dyld before the UI starts. Missing or invalid metadata also requests rebuild/reinstall. CLI help and default-config output remain available without the playback libraries. ## Usage
`Contents/Resources/licenses` includes upstream notices, pinned provenance, native build flags and the dependency recipe; `sources/native` contains the exact unmodified FFmpeg/dav1d/liblzma archives. Shared libraries can be replaced with ABI-compatible modified versions; re-sign modified code and the app afterward. The standalone yt-dlp combined executable is **GPLv3+**, not solely the source project's Unlicense. Helper source archives and upstream Deno/V8 notices provide provenance, not a complete public-redistribution compliance or corresponding-source offer. This recipe is for personal use; public binary distribution requires auditing all bundled helper dependencies and applicable source obligations. Open a file with **⌘O**, drag it onto the window, or use Finder's **Open With**. Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
The declared app executable stays running after preflight and loads `Contents/Frameworks/libterebi-player.dylib` in-process. It does not `exec` another executable: LaunchServices retains the real app PID and identity, so third-party Now Playing source lists such as Vorssaint can discover it. Missing/unloadable player libraries or an invalid entry point also show the native rebuild/reinstall error dialog. | Action | Default shortcut |
| --- | --- |
| Play / pause | Space or K |
| Seek backward / forward 5 seconds | ← / → |
| Seek backward / forward 10 seconds | J / L |
| Step one frame backward / forward (pauses playback) | , / . |
| Volume down / up | ↓ / ↑ |
| Mute / unmute | M |
| Fullscreen | F or ⌃⌘F; Esc to leave |
| Compact mode | P or ⌃⌘P |
| Always on top | ⇧P |
| Close media | ⇧⌘W |
| Quit | Q or ⌘Q |
Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks.
### YouTube
Playback defaults to the best available SDR resolution up to **1080p**. Use **gear → Quality** to select another available resolution, including 4K, or enable automatic quality (off by default). **Show Buffer Info** displays the retained network buffer.
Only individual videos are supported, not playlists or channels. Private, age-restricted, region-blocked, or sign-in-required videos may fail; browser cookies are not read. The extractor is bundled, so YouTube compatibility updates require rebuilding/reinstalling terebi, not updating an external yt-dlp.
### Chromecast
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Keep the Mac awake and connected: it relays the media to the receiver.
Existing controls operate remote playback. **Play on This Mac** returns to local playback at the receiver's position. Opening different media ends casting. Frame stepping and YouTube quality changes are local-only; live Cast playback is not seekable. Cast output is limited to 1080p/30 fps. The screen-and-triangle icon does not provide AirPlay support.
Cast trusts the selected LAN endpoint's self-signed certificate; use it only on a trusted network. Receiver captions and perceived A/V synchronization still need physical-device verification.
## Command line ## Command line
`make` also builds `.build/terebi`, a native CLI launcher: Before installation, use `.build/terebi` instead of `terebi`:
```sh ```sh
.build/terebi "/path/to/video.mp4" terebi "/path/to/video.mp4"
.build/terebi 'https://youtu.be/jNQXAC9IVRw' terebi 'https://youtu.be/jNQXAC9IVRw'
.build/terebi --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
.build/terebi -- -video.mp4 terebi --new-instance --no-config "/path/to/video.mp4"
.build/terebi --new-instance terebi -- -video.mp4
.build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' terebi --help
.build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' --progress-file "/private/session/progress.jsonl"
.build/terebi --new-instance --no-config "/path/to/video.mp4"
.build/terebi --help
.build/terebi --default-config
``` ```
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`. The CLI opens one file or video, reusing the running player by default. With no arguments it opens or activates the player without replacing media. Put `--new-instance` and `--no-config` before media arguments. Launch success means macOS accepted the request, not that playback succeeded; playback errors appear in the window.
The launcher locates its enclosing `terebi.app` through its actual executable path, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. The CLI travels inside the app; `.build/terebi` is a convenience symlink. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share saved progress, but configuration and control overrides belong to each session. Quit running players after installing an update. ### Resolved sources and progress reporting
Help and `--default-config` exit with status **0** without opening the GUI, reading configuration, or checking/loading FFmpeg. The latter prints complete built-in TOML to stdout, including every named key action and explicit `[]` entries for unbound actions; it uses the player's authoritative defaults rather than your current settings. It accepts no media argument and never creates or writes a config file. Invalid arguments, unsupported URLs, missing media files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**; unreadable or invalid configuration exits **78** with the file path and diagnostic. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface. Miruro playback requires a freshly resolved source document from an external caller such as Tubular; terebi does not discover episodes or accept Miruro page URLs directly.
Reporting destination preflight failures exit **73** without launching. The GUI still creates the file atomically and reports initialization failures with a native alert; those can occur after the launcher has returned successfully.
## Configuration
Every fresh app startup automatically reads **`$XDG_CONFIG_HOME/terebi/config.toml`**, falling back to **`~/.config/terebi/config.toml`** when `XDG_CONFIG_HOME` is unset, empty, or not absolute. The CLI forwards its resolved path because Finder/LaunchServices does not inherit the shell's XDG environment. Set `XDG_CONFIG_HOME` in the GUI launch environment if Finder should use a different directory.
The file is optional: a missing file means built-in defaults, and terebi never creates a file or directory on its own. The complete built-in configuration is:
```toml
[playback]
automatic_quality = false
[ui]
buffer_info = false
compact_snap_to_edges = true
compact_title_progress = "time"
[keys]
play = ["k", "space"]
pause = ["k", "space"]
mute = "m"
unmute = "m"
volume_up = "up"
volume_down = "down"
seek_forward_5s = "right"
seek_backward_5s = "left"
seek_forward_10s = "l"
seek_backward_10s = "j"
step_frame_forward = "."
step_frame_backward = ","
enter_fullscreen = ["f", "ctrl+cmd+f"]
exit_fullscreen = ["f", "ctrl+cmd+f", "escape"]
enter_compact = ["p", "ctrl+cmd+p"]
exit_compact = ["p", "ctrl+cmd+p"]
enable_always_on_top = "shift+p"
disable_always_on_top = "shift+p"
open_file = ["o", "cmd+o"]
open_youtube = "cmd+shift+o"
close_file = "cmd+shift+w"
quit = ["q", "cmd+q"]
show_buffer_info = []
hide_buffer_info = []
enable_compact_snapping = []
disable_compact_snapping = []
enable_automatic_quality = []
disable_automatic_quality = []
quality_default = []
```
All settings belong to `[playback]`, `[ui]`, or `[keys]`; these sections can appear in any order. Omitted sections use built-in defaults. An assignment belongs to its current TOML section, so place `automatic_quality` beneath `[playback]`, not at the root or under another header.
Omitted settings/actions retain their defaults. A shortcut string or string array **replaces** an action's default bindings; `[]` unbinds it. Keys are case-insensitive logical keys, not physical key positions. Use exact modifiers `cmd`, `ctrl`, `alt`/`option`, and `shift`, joined with `+`; uppercase `P` alone does not mean Shift+P. Named keys are `space`, `escape`, `return`, `tab`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown`, `plus`, and `f1`–`f35`; printable characters such as `,` and `.` also work. OS-reserved Cmd+Tab is rejected.
Bindings name explicit actions, not toggles. The same chord may be shared only by inverse pairs shown above: play/pause, mute/unmute, enter/exit fullscreen or compact, enable/disable always-on-top, show/hide buffer info, and enable/disable snapping or automatic quality. A press performs **one currently applicable action**, including pending playback/window intent; already-satisfied actions do nothing. Give each side different chords for dedicated enable/disable keys. Sharing a chord between unrelated actions, duplicate normalized chords, unknown actions, and malformed shortcuts are errors.
For YouTube, bind `quality_<height>p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. YouTube captions and caption key actions are not implemented; resolved-source VTT selection uses the captions menu. Caption and `toggle_*` action names are rejected.
Behavior/UI switches must be TOML booleans; `[ui].compact_title_progress` is a string: `"time"` (default), `"percentage"`, or `"none"`. Unknown keys, unsupported values, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes.
Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history and remembered compact width remain separate in UserDefaults and are not reset by `--no-config`; legacy UserDefaults configuration settings are no longer read or written.
## YouTube
The app bundles [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, Python/default dependencies, and Deno. YouTube playback does not search Homebrew or fall back to external PATH executables; a missing bundled helper reports a damaged installation.
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.
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
Seekable network playback targets **60 seconds ahead and 30 seconds behind** using retained compressed audio/video packets, with a shared **128 MiB** byte budget. Time targets are best-effort: bandwidth, bitrate, packet/keyframe layout, source availability, and byte pressure can shorten the window. Playback starts promptly and read-ahead continues in the background, including while paused. Cached seeks replay from the preceding usable keyframe without a new HTTP request; seeks outside the retained window refill normally. This is an in-memory sliding cache, not a full-video download or persistent disk cache.
Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, continuing sequentially and reusing persistent connections when the server permits. This avoids the open-ended-request throttling seen on some YouTube CDNs; URL extraction alone does not apply yt-dlp's download chunking. Native [`request_size` and `multiple_requests`](https://github.com/FFmpeg/FFmpeg/blob/n9.0.1/doc/protocols.texi#L462-L485) handle continuation without a separate downloader. Large `short_seek_size` read-ahead stays disabled: an uncached seek must escape a stalled response, not wait to drain it. Required headers, fifteen-second I/O deadlines, cache limits, and cancellation remain intact. Live sources keep their existing transport behavior. This removes avoidable request-level throttling, not bandwidth/CDN limits: a minute is a target, not a startup requirement or guaranteed reserve.
Recorded, range-seekable HTTP(S) inputs automatically reconnect after interrupted reads, resuming at the same byte offset without reopening media or discarding retained packets. FFmpeg caps reconnect attempts and backoff; the reader's **fifteen-second total I/O deadline** still applies, with a **five-second socket timeout** leaving room for recovery. Position, playing/paused intent and selected headers remain unchanged. Seek, replacement, close and quit can interrupt retries. Genuine EOF is not retried; live/non-range streams are not restarted. Persistent failures still surface an error. Expired signed URLs are not automatically refreshed.
**gear → Show Buffer Info** enables the optional bottom readout in both player modes; it is **disabled by default**, configurable with `[ui].buffer_info`, and its menu toggle is session-only. When enabled, actual retained times appear as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead regardless of this preference; its tooltip and VoiceOver retain fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Hiding the readout does not affect caching or automatic quality. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range.
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `[playback].automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, update the pinned helper versions/checksums and rebuild/reinstall terebi; upgrading an external Homebrew yt-dlp does not change the bundled extractor.
## Resolved HLS sources (Miruro / Tubular)
Tubular integration handoff: [launch contract, progress schema and verification evidence](docs/miruro-playback-handoff.md).
Tubular owns discovery, episode/provider selection and fresh source resolution. Terebi accepts a resolved source document; it does not scrape Miruro, embed `strm.cx`, refresh expired sources or bypass protection.
```sh ```sh
terebi --new-instance --source-file '/private/session/source.json' \ terebi --new-instance --source-file '/private/session/source.json' \
--progress-file '/private/session/progress.jsonl' --progress-file '/private/session/progress.jsonl'
``` ```
`--source-file` requires `--new-instance` and accepts a filesystem path (relative paths resolve against the shell's directory). The CLI validates the document, then forwards its absolute path to the GUI; only paths, not stream URLs or header values, enter launch arguments. Create the session directory with mode `0700` and the source file with mode `0600`. Keep the file unchanged and present for the entire player session; launcher success does not acknowledge that the GUI has read it. The caller owns cleanup. Both options require `--new-instance`; `--progress-file` also works with YouTube. Use a private session directory and a nonexistent report destination. Keep the source file present and unchanged during playback; the caller owns cleanup. Reports contain playback facts, not watched-state decisions.
Source document syntax (replace example URLs with freshly resolved resources): See the [integration contract](docs/miruro-playback-handoff.md) for source JSON, resource-header/origin restrictions, and progress schemas. Tubular still needs to adopt Miruro's version-2 progress identity.
```json ## Configuration
{
"version": 1,
"url": "https://cdn.example/master.m3u8?token=fresh",
"headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"},
"identity": {"provider": "miruro", "media_id": "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw", "episode": 1},
"title": "The Apothecary Diaries — episode 1",
"subtitles": [{
"url": "https://cdn.example/english.jpeg?token=fresh",
"format": "vtt",
"language": "en",
"label": "English",
"default": true,
"headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"}
}]
}
```
All fields except `title` are required; `subtitles: []` and `headers: {}` are valid. Subtitle headers never inherit stream headers: Tubular must explicitly supply the appropriate map for each resource. Values are preserved without trimming; invalid HTTP names, duplicate case-insensitive names and control characters are rejected. Documents are limited to 1 MiB, 32 subtitle tracks and 64 headers per resource. Miruro IDs are nonempty ASCII letters/digits/`-`/`_` (up to 512 bytes); episode numbers are integers from 1 through 2147483647. The optional config is `$XDG_CONFIG_HOME/terebi/config.toml` when `XDG_CONFIG_HOME` is absolute, otherwise `~/.config/terebi/config.toml`. Omitted settings use defaults. To print all supported settings and key actions:
Native FFmpeg plays the master and nested playlists, media segments, AES-128 keys and fMP4 init resources with the stream's headers. Relative URLs use native HLS resolution; supplied URLs and query tokens are retained, not synthesized onto unrelated references. Resolved media resources must remain on the initial stream's origin (scheme, host and effective port). All media redirects, including same-origin redirects, are rejected before a second request; this deliberately prevents FFmpeg from forwarding private headers to an unrelated destination. YouTube keeps its existing transport policy.
Sun can serve MPEG-TS segments as `.jpg`/`image/jpeg`. Resolved HLS uses content probing instead of FFmpeg's filename-extension gate; URLs are not rewritten. The HTTP(S)-only, same-origin and no-redirect restrictions still apply.
External subtitles use explicit `format: "vtt"` plus WebVTT content, never the filename extension or MIME type. The captions menu shows labels/languages, selects the declared default and permits Off/another track. Plain-text cues follow acknowledged playback position, including backward/forward seeks; WebVTT positioning, CSS and typographic styling are not applied. Subtitle loading is bounded to 4 MiB with 15-second request/20-second resource timeouts. Same-origin subtitle redirects retain that resource's headers; cross-origin redirects are rejected. Load errors appear beside the captions menu, independently of media errors.
FFmpeg diagnostics omit private detail strings; playback failures still expose the operation and native error. Source/subtitle validation and transport errors do not interpolate supplied URLs or header values.
Miruro's progress identity is independent of its source address. YouTube records stay version 1; Miruro records use version 2:
```json
{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"OERe_a0YS4h-DlsBDOeXwX7HOO6316tw","episode":1},"position_seconds":123.5,"duration_seconds":1500.0,"completion_eligible":true}
```
Events, acknowledged-position reporting, nullable duration and completion eligibility retain the semantics below. Miruro bookmarks use the same stable episode identity. **Tubular coordination required:** its current `src/video_state.rs` accepts only canonical YouTube/version-1 records; it must adopt this version-2 identity before Miruro watched tracking works. This is the implemented Terebi contract, not a claim that Tubular has already adopted it.
`make check-subtitles`, `make check-resolved` and `make check-network` use deterministic local VTT/HLS fixtures; `make check-startup` exercises the actual CLI-to-GUI handoff. Public-provider live playback is never part of the default suite; Cast's rolling-window check uses generated media and an injected pacing clock. With a private, freshly resolved source document, explicitly run `.build/resolved-playback-check /absolute/source.json` for native video/audio and subtitle/seek smoke. Visual rendering and perceived synchronization still require a visible-app check.
Verification recorded for the direct `ssub`/`sun` source: a freshly resolved native stream decoded video/audio and advanced, selected English VTT, and matched the model's subtitle text to the expected cues after forward/backward acknowledged seeks. Local encrypted MPEG-TS and fMP4 fixtures also verified disguised image suffixes, headers on master/variant/segment/key/init requests, query preservation, foreign-origin/redirect isolation and redacted errors. `TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup` passed actual CLI launch into two independent GUI episodes, resource-specific subtitle headers and version-2 reports. CLI, subtitle, progress, reporting, YouTube resolver and system-playback checks passed.
The full `make check-startup` rerun passed, including held-key suppression, YouTube progress/end transitions and independent Miruro launches. Earlier runs failed held-Space and YouTube end-transition assertions; possible manual interaction was reported, but causality is not established. The rerun required no playback-code changes. Visual subtitle appearance and perceived A/V synchronization remain human verification, and Tubular's version-2 consumer change remains external coordination.
## Chromecast
Open media, then choose the **Cast** icon (the Apple TV/AirPlay-style screen-and-triangle symbol) between pin and settings in the top-right titlebar container, or between exit compact mode and settings in compact controls, or **Playback → Cast to Device → Find Cast Devices…**. Allow macOS Local Network access, then select a discovered Chromecast/Cast-enabled TV on the same LAN. The icon does not add AirPlay support. No Chrome, hosted custom receiver, Google developer registration, or external casting runtime is required.
The Cast icon uses 15 points in the titlebar and 17 points in compact controls, within the same 24 × 22-point button frame.
Terebi pauses local output before transferring the acknowledged position and play/pause intent to Google's Default Media Receiver. The existing controls, keyboard shortcuts and macOS media commands control remote playback, seeking, volume/mute and resolved-source subtitles. **Play on This Mac** restores receiver-confirmed position and intent; local volume is preserved independently. Connection/load failures return to local playback with an error. Opening another source ends casting and opens it locally. Frame stepping and YouTube quality changes are local-only; automatic quality is suspended while casting.
TLS connection establishment has a twelve-second deadline; cold receiver launch has a separate thirty-second deadline and accepts validated asynchronous readiness without waiting for the original launch acknowledgement. Volume controls retain the latest requested value until its acknowledgement, ignore older feedback, and do not reposition the native slider during a drag. External TV volume changes still update idle controls.
The Mac serves session-only capability URLs over HTTP. Keep it awake and connected, and allow incoming connections through its firewall. The relay exposes only the chosen media and subtitle resources, never arbitrary filesystem paths or a general URL proxy. Disconnect, replacement and quit cancel delivery and invalidate the listening endpoint.
Local files, resolved Miruro HLS and YouTube's combined/separate video/audio inputs are delivered as six-second HLS segments. Compatible, independently decodable H.264/AAC is remuxed; other supported inputs use VideoToolbox H.264 (including its platform software fallback) and stereo AAC. Output targets at most **1920×1080 at 30 fps**, H.264 Main/High level 4.1 or lower, with preserved display aspect ratio. Recorded segments are generated on demand, including distant seeks; there is no full-movie download or preprocessing step. Live output keeps eight segments (about 48 seconds) and is not seekable. Audio-only sources are supported.
Recorded network delivery uses the native player's bounded compressed-packet buffer: up to 128 MiB shared between inputs, targeting sixty seconds ahead and thirty seconds behind (byte pressure can shorten retention). Combined HLS supplies video/audio from one selected-stream reader; unused quality variants are disabled. Sequential and nearby seeks reuse packets, including decoder/AAC preroll; true cache misses seek upstream once. Nearby forward requests wait for existing read-ahead instead of cancelling and restarting its fetch. Eight completed output segments are also cached (at most 256 MiB), so repeated, HEAD and range requests reuse encoded bytes. Uncached generation is serialized; GOP preroll, transcoding, provider slowness and receiver buffering can still delay a fresh Cast seek.
Miruro retains its HTTP(S)-only, same-origin media and no-redirect policy. Public YouTube HLS can cross CDN origins, but supplied headers never follow it to another origin. All Cast media redirects are rejected. WebVTT is fetched with each track's own headers and the existing bounded, same-origin subtitle redirect policy, then served with CORS. Private upstream addresses and headers are not sent to the receiver or included in errors.
The Cast channel accepts the selected receiver's self-signed TLS certificate: encryption on a trusted LAN, **not** Google device authentication or public-CA receiver identity verification. Discovery and receiver control trust the selected LAN endpoint. Native media decoding has a 4096×2160-pixel budget, individual FFmpeg allocations are capped at 128 MiB after Cast initialization, and each outgoing segment is capped at 32 MiB. Recorded inputs longer than fourteen days are rejected.
`make check-cast` exercises a real self-signed TLS loopback receiver, decoded relay output for all three source paths, header/origin isolation, recorded seeking, VOD/live audio continuity, anamorphic video, capability expiry, controlled protocol deadlines/live pacing, and complete player handoff/back-to-Mac recovery. Regression checks cover slow/asynchronous launch, stale volume feedback, native slider tracking and split-stream seek work: repeated segments add no upstream requests, and indexed distant/adjacent seeks add no origin-zero reprobes. A production-paced live HLS smoke also decoded A/V across a segment boundary and shut down. **YouTube Cast playback has been observed on a physical receiver; these fixes, receiver captions and perceived A/V synchronization still need human verification.**
Authenticated multi-variant HLS regressions gate upstream responses and verify no unused-quality downloads or duplicate retained-media reads, nearby rewinds, distant cache misses, decoded A/V positions and AAC continuity, and cancellation while upstream remains blocked. Native network playback checks cover the shared reader's existing callers.
## Resume playback
Reopening a local video, YouTube video or resolved Miruro episode automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL; Miruro uses its opaque media ID and episode number. Newly resolved addresses therefore share progress without saving CDN tokens as identity. The app does not automatically reopen the last video on launch.
Progress is stored locally in UserDefaults, checkpointed every five seconds of playback progress, and saved on pause, close, replacement, and normal quit. Only authoritative, acknowledged positions are saved—not an unfinished seek or slider drag. Finished videos clear their bookmark and reopen from the beginning; a bookmark beyond the current video's duration also restarts at the beginning. Failed opens preserve existing bookmarks. Live/non-seekable inputs and audio-only media do not record progress.
Each video's bookmark has its own UserDefaults key, so separate player processes cannot erase unrelated bookmarks by rewriting a cached history dictionary. Existing bookmarks migrate automatically from the old dictionary. If two instances play the same video, the most recent checkpoint wins; progress is shared, not a separate bookmark per window.
## Playback progress reporting
Opt in per player session with **`--progress-file PATH`**, which requires **`--new-instance`**. The option may follow `--url URL`; relative report paths resolve against the invoking shell's directory before being forwarded to the GUI. Reporting is disabled without the option and is not a config.toml setting.
Create a unique private directory and supply a **nonexistent** file:
```sh ```sh
session=$(mktemp -d /tmp/terebi-progress.XXXXXX) terebi --default-config
terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' \
--progress-file "$session/progress.jsonl"
``` ```
The shared C++ backend owns reporting inside the GUI process, independent of the launcher and UserDefaults. It exclusively creates a regular file with owner-only read/write permissions (`0600`), rejecting existing destinations and symlinks. Parent directories must already exist; the caller owns directory/file cleanup. Concurrent players cannot claim the same destination. Example partial configuration:
Each complete YouTube line is a version-1 JSON record (resolved Miruro sources use the version-2 identity documented above): ```toml
[playback]
automatic_quality = true
```json [ui]
{"version":1,"event":"progress","media_url":"https://www.youtube.com/watch?v=jNQXAC9IVRw","position_seconds":123.5,"duration_seconds":200.0,"completion_eligible":true} buffer_info = true
compact_snap_to_edges = true
compact_title_progress = "percentage" # "time", "percentage", or "none"
[keys]
play = "space"
pause = "space"
quit = "cmd+q"
quality_2160p = "ctrl+4"
``` ```
`event` is `progress`, `ended`, or `closed`. Identity is the canonical YouTube webpage URL, never the extracted stream URL. Position is finite, nonnegative native playback state; duration is positive and finite or `null`. Eligibility is false for live/non-seekable inputs, unknown duration, and audio-only media. Shortcut strings or arrays replace an action's defaults; `[]` unbinds it. Modifiers use `cmd`, `ctrl`, `alt`/`option`, and `shift`, joined with `+`. A chord may be shared by inverse actions such as play/pause, but not unrelated actions. Invalid settings and conflicting shortcuts are errors.
The backend reports initial ready state after acknowledging restoration, approximately every five seconds while playing, promptly at the first eligible 95% crossing, on acknowledged pause/seek/frame-step transitions, and at native end, replacement, close, and normal quit. Pending seeks and slider previews are not actual positions. Quality reopenings preserve identity and do not produce `closed`; local-media replacement closes the outgoing YouTube identity and produces no local-media records. Failed or cancelled opens never report the requested video's progress. Settings load once at startup. Menu changes are session-only and never write the file; start a fresh player to apply edits. `--no-config` uses built-in settings on a fresh launch, not in an already-running player, and does not clear saved progress.
Writes are serialized on a separate thread; normal shutdown drains complete records. Write failure disables reporting and surfaces a separate warning without stopping playback. Consumers must tolerate duplicates, incomplete trailing lines, and abrupt termination; a `closed` record is not guaranteed. ## Limits
Terebi reports facts, not watched status. A consumer such as Tubular decides completion using `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`, for the matching video identity. This is a **position threshold**, not viewing coverage: seeking near the end counts. Window closure or successful launcher exit alone does not. - SDR output only; PQ/HLG HDR is rejected. No playlists, embedded/YouTube captions, manual audio-track selection, rotation, or deinterlacing. External resolved-source WebVTT subtitles are supported.
- Video decoding is software-only. Backward frame stepping can be slow on long-GOP or unindexed media.
- Network buffering is bounded and memory-only; live/non-seekable sources disable seeking and frame stepping. Expired stream URLs are not automatically refreshed.
- Audio-device changes during playback are not handled.
## Playback UI ## Development checks
The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point transparent titlebar with native traffic lights and file icon, plus a frosted 440 × 68-point floating controller. Conjure-inspired **Sunset** styling uses translucent dark-brown glass, warm cream primary text, sand secondary text, and a fixed orange accent for the volume fill, active pin, and drop target. Native slider thumbs and buffered ranges share the palette; warning symbols retain their semantic yellow. 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.
The empty player uses the same tinted HUD-glass material, faint warm outlines, 12-point rounded corners, and Sunset palette in a centered welcome card. File and YouTube opening actions are separate, equally sized buttons with a 10-point gap, subtle orange fills, and matching half-point sand outlines. The YouTube button toggles an outlined inline URL field below the buttons, spanning their combined width including the gap. Its placeholder uses the secondary sand text color at 60% opacity to distinguish it from entered text. Showing it focuses the field; hiding it releases text-input focus and preserves the draft. The field fades in and out without a slide while the card adjusts over 220 ms with ease-in/out timing; Reduce Motion disables the animation. Return or the arrow button submits through the existing YouTube URL validation. Other URL-opening entry points retain their dialog. The card fits the minimum window, and the full window still accepts media-file and YouTube-link drops.
The inline URL field, including its placeholder, uses the same 12-point system font as the drop hint below it. Its height follows the content with 4-point vertical padding rather than matching the taller opening buttons; the submit arrow retains its 24-point click target.
With no media open or while casting, the full window uses native behind-window HUD glass with a 35%-opaque brown tint. Static, cached amber/rust radial gradients add ambient color to the empty and casting screens; they disappear during loading or errors, and are never drawn over local video or playback chrome. Glass title/control/error surfaces use the same brown tint with half-point sand outlines. Decorative drawing does not intercept input. The idle or casting Metal surface stays attached but hidden; local playback retains a solid black background and letterboxing without tinting the video. Closing media or starting casting restores the frosted background. macOS controls the material’s appearance, including Reduce Transparency.
In normal mode, the right-hand playback control group is captions, then compact mode. The captions selector is omitted when no external subtitle tracks are available, without reserving a caption slot. Its native symbol uses a 13-point configuration within a 24-point click target. The native pull-down menu includes a visible Off option and prefers opening above the button; AppKit can reposition it to fit the screen. In compact mode, subtitle selection and Off are available through the settings button's Subtitles submenu; the compact cog uses a 21-point symbol to balance the adjacent icons. The settings menu uses a plain button style so its symbol respects that size and inherits the same cream icon color as the other controls, rather than the orange accent tint. Subtitle glass hugs the text with 16-point horizontal and 12-point vertical internal padding, with the complete container capped at 80% of the window width; longer text wraps within that limit. Its HUD-glass background uses 60% opacity without dimming the text. Visible compact controls raise subtitles by the actual compact control height plus spacing, rather than the normal controller's clearance. Subtitle-load errors remain visible independently of auto-hidden controls.
Accepted relative seeks, including keyboard shortcuts, preserve control visibility instead of revealing hidden chrome. Seek failures still reveal the error; dragging the timeline continues to show the controls.
Normal and compact titlebars have no full-width glass strip or separator: content extends beneath AppKit’s transparent titlebar using `fullSizeContentView`, `titlebarAppearsTransparent`, and `titlebarSeparatorStyle = .none`. Titles retain their rounded HUD-glass backdrop. In normal mode, pin, Cast, and settings share a separate container matching the title's 24-point height, horizontal padding, vertical inset, tint, outline and corner radius. Cast sits between pin and settings; all three retain 24-point click targets. That button container is not shown in compact mode; only the compact title and adjacent progress occupy the top row with 8-point outer insets. Native traffic lights and window dragging are preserved.
Compact titles default to `<title> · <position> / <duration> <unit>`, using acknowledged playback position rather than slider previews. Total duration selects the format: below one minute, `12 / 45 s`; from one minute to below one hour, `05:12 / 42:08 m`; from one hour onward, `01:23 / 02:10 h`. Values truncate to the displayed precision. Set `[ui].compact_title_progress = "percentage"` to append only a whole percentage number (`<title> · 59`, without a percent sign), or `"none"` for title-only display. Percentages truncate and stay within 0–100. Loading, failed, live, unknown-duration and closed media have no progress suffix. Normal-mode titles remain unchanged.
Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window.
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and the last manually chosen compact width (320 points before any resize). The width persists across mode changes and player restarts; height follows the current video's display aspect where usable screen bounds and the 160 × 90-point minimum allow, with extreme ratios retaining letterboxing. Normal-mode resize, transition animations, and automatic aspect/screen fitting do not overwrite the saved preference. Screen fitting may temporarily reduce the displayed size without reducing the saved width. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Its three-button hover row is play/pause, restore, then settings on the right. Settings includes subtitle selection and Off; keyboard playback controls remain available.
Compact-mode shortcuts and menu actions remain available during YouTube URL resolution, media opening, and network buffering. While metadata is unavailable, the window uses a provisional video ratio; decoded metadata then updates its geometry without leaving compact mode.
Entry and restoration animate the same window's position and size over **250 ms** with ease-in/ease-out timing while normal and compact controls cross-fade; playback and the Metal view stay in place. macOS **Reduce Motion** disables both animations. Rapid toggles keep the latest destination and finish the current native frame animation before changing direction; native fullscreen transitions are also serialized. Media close, errors, shutdown, and explicit fullscreen handoff restore immediately rather than leave a frame animation pending.
Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `[ui].compact_snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app.
Default shortcuts and their explicit action names are listed in **Configuration**. Playback bindings apply only to the playback window and do not intercept text editing, file/URL dialogs, plain Tab navigation, or VoiceOver's Control+Option commands. Native Quit remains available while editing: its menu prefers the first configured Command chord (⌘Q by default), falling back to the first binding when none has Command. Plain q remains text input while editing. Configured modifier combinations work in playback. Holding a playback/window/settings key performs it once; seek, volume, and frame-step keys may repeat. Other player menus show the first configured chord and use the same explicit actions rather than retaining hidden fixed player shortcuts; all configured chords work from playback. Return/Space navigation in an open menu activates its selected action.
macOS media keys and Now Playing controls use native MediaPlayer commands, including while the window is unfocused: play/pause toggles playback, and previous/next seeks backward/forward five seconds instead of switching tracks. System skip controls prefer five-second intervals. Live/non-seekable inputs disable seeking while retaining play/pause. Now Playing reports the current title, duration, elapsed time, and playback state; opening/replacing, closing, failed playback, and quitting release unavailable controls. No Accessibility or Input Monitoring permission is required. macOS chooses which active media app receives the keys.
Initial window setup and returning from the file/YouTube dialogs leave keyboard focus on playback rather than automatically highlighting the titlebar pin. Tab navigation and native focus indicators remain available when choosing controls explicitly.
Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame.
- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking normal-mode video also toggles fullscreen.
- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The enabled volume thumb is opaque cream; secondary sand marks the seek thumb and buffered range, with dimmed disabled thumbs.
- **Single-click normal-mode video:** hide controls. Mouse movement reveals them; video controls fade after 2.5 seconds, including while paused. While the pointer is inside, hovering the normal titlebar or either controller and an inactive normal-mode window keep them visible; audio-only controls remain visible. Leaving the window starts the title/control fade immediately in either mode, including paused, loading, and audio-only playback; late hover/focus updates do not reshow them. Reentry reveals them. Active dragging/seeking, errors, empty-window opening actions, and VoiceOver remain protected; a drag released or seek acknowledged outside hides the chrome. Tab navigation can explicitly reveal controls without moving the pointer. Compact hover and window exit never hide the desktop cursor.
- Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top.
Only implemented actions have controls; there are no placeholder playlist, subtitle, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift).
## Where to change things
Playback terms are defined in [CONTEXT.md](CONTEXT.md).
- `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, live status, canonical `media_url`, and `initial_position`. Initial network restoration completes before reporting ready state. `player_set_progress_file` initializes opt-in reporting before opening media; its errors and `player_get_progress_error` are separate from playback errors. `player_shutdown` stops the worker and drains reporting, returning final reporting errors before handle destruction. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `buffered_start`/`buffered_end` report a conservative retained/playable interval in normalized seconds, constrained by required tracks; unavailable/local/pending ranges are `0, 0`, the start may include reusable history before `position`, and the end is no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded decoded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. Seeks stop output and flush decoder/output state; network inputs replay retained packets or seek/refill on cache misses.
- `Sources/Core/progress_reporter.hpp` and `.cpp`: shared JSONL schema, canonical identity, acknowledged snapshots, eligibility, clock-injected cadence/threshold detection, lifecycle records, and asynchronous file writer. File creation/write uses POSIX facilities; reporting policy is independent of AppKit/Swift.
- `Sources/Core/network_input.hpp`: network-only demux readers with a shared compressed-byte budget, playback-anchored read-ahead/history, keyframe/audio-preroll seek indices, conservative reordered-video timestamp frontiers, cached replay, asynchronous miss seeks, fifteen-second I/O deadlines, and interruption on miss/replacement/close/destruction. Cache hits leave outstanding fetch-frontier I/O intact. Remote input protocols exclude local-file access through manifests.
- `Sources/Core/network_policy.hpp`: shared URL, header and origin validation used by local network playback and Cast delivery.
- `Sources/Core/cast_media.h` and `.cpp`: bounded native LAN HLS serving, on-demand recorded segments, rolling live delivery, remux/transcode, subtitle capabilities and cancellation.
- `Sources/App/CastSession.swift`: Bonjour discovery, bounded Cast v2 TLS/protobuf framing, Default Media Receiver ownership, acknowledged controls, heartbeat and cancellation.
- `Sources/App/CastMediaServer.swift` and `CastControls.swift`: off-main native delivery lifetime, shared authenticated WebVTT transport, and device menus.
- `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 `PlayerModel` retains the control interface. Private `PlaybackTransition` owns extraction lifetime, open/seek acknowledgement, playback intent, local restoration, and checkpoint eligibility. `MediaSource`/`MediaChange` feed one opening path for files, YouTube, and quality changes. Native observations checkpoint before consuming local restoration; network restoration and JSONL reporting remain in C++. Panels, controls, visibility, video sizing, and window integration remain presentation.
- `Sources/App/PlayerActions.swift`: native event-to-chord adaptation, editing/accessibility eligibility, explicit context-menu dispatch, and scene-focused SwiftUI commands sharing the model's action bindings.
- `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do.
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source.
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, serialized frame/fullscreen animations with cancellation, Reduce Motion and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle.
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, strict bundled yt-dlp/Deno discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton window.
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared bundled-library preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the five versioned library filenames relative to `Contents/Frameworks`.
- `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, nested behavior/UI/key schema, XDG path resolution, read-only strict TOML loading, and startup bypass. `PlayerAction.swift` defines explicit supported actions/inverse pairs; `KeyBindings.swift` parses chords and validates/indexes the complete binding map. `Package.swift`/`Package.resolved` pin the statically linked decoder.
- `Makefile`, `Scripts/dependencies.py`, `Scripts/bundle.py` and `Dependencies.lock.json`: pinned/checksummed native library and helper preparation, compiler/linker commands, SwiftPM configuration-library build, private library relocation/signing, developer/release bundles, installation, and checks. No generated Xcode project.
## Runnable check
The full suite generates media and reference PCM using the development-only `ffmpeg`/`ffprobe` command-line tools (`brew install ffmpeg`). They are not required to build, install, or run the app.
```sh ```sh
brew install ffmpeg # fixture generation only; not required to build or run terebi
make check make check
``` ```
Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing and queued seek acknowledgement, exact bidirectional VFR/B-frame stepping across keyframes, repeated-step bursts beyond the ordinary command capacity, mixed seek/direction ordering, replacement media superseding a long pending run, first/last-frame boundaries, stepping pause/resume, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test. The full suite requires a graphical macOS session and a working audio device; it launches isolated app instances and plays short test audio. Targeted `check-*` targets are listed in the [Makefile](Makefile). Visual appearance, physical media-key routing, and receiver behavior still need manual checks.
`make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, coarse container timestamps, and AAC in MPEG-TS with millisecond-rounded timestamps. Audio continuity allows up to two milliseconds of timestamp jitter (or a larger container tick) without inserting or dropping samples; this covers Miruro streams whose advertised 90 kHz timebase overstates their timestamp precision. It verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook. Playback terminology: [CONTEXT.md](CONTEXT.md).
`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, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, remembered manual compact width across reentry/controller recreation with a different aspect, isolation from programmatic/normal-window resizing, 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.
The native window check also covers queued entry/exit reversals, exact snapshot recovery, urgent nonanimated restoration, close/reattachment ignoring stale completions, and injected Reduce Motion. Animation smoothness and control cross-fades still need visual verification in the app.
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, real subprocess output, bounded pipe draining, cancellation, request isolation, and damaged/moved helper discovery using isolated fixtures. It also runs the actual bundled extractor against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K; no external network access is required.
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`.
The network check also disconnects combined, secondary-audio and primary-video responses after native playback starts. It verifies retained coverage, unchanged playing/paused intent, decoded continuation beyond the interrupted prefix, actual socket-timeout recovery, bounded persistent failures, and seek/close/replacement/destruction during retry.
`make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and session-only opt-in/configuration restoration. The quality clock is injected; tests use isolated playback history and muted playback. Both targets are included in `make check`; native adaptation uses the app's bundled yt-dlp and Deno. No external network is required.
Adaptive transition checks also gate a real extraction, cancel it by pausing, and verify that late completion cannot reopen quality or resume paused playback. Failed manual quality changes preserve the original media, position, and prior playing/paused state.
The gated YouTube check also exercises compact shortcut/control entry and restoration during initial resolution, staying compact across empty playback updates and loading completion, and switching modes while network playback is buffering.
`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, isolated preference domains, independently initialized writers preserving each other's videos, and migration of existing bookmarks. It is included in `make check` and does not touch real playback history.
`make check-reporting` decodes real JSONL files with an injected clock: initial/period boundaries, pending versus acknowledged seeks, threshold/pause/end/close facts, quality/replacement identity, nullable duration and eligibility, exclusive private creation, existing/symlink/concurrent rejection, and isolated OS write failure. `make check-network` also exercises the real backend's acknowledged initial restoration, audio-only/live eligibility, failed/cancelled opens, final drain, and continued playback/seek/replacement after a failed writer. Both are included in `make check`.
`make check-cast` is included in `make check`; it needs no TV or live provider. `make check-network` also verifies that immediate close drains receiver-confirmed progress rather than the abandoned decoder position.
`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, `--no-config` alone and combined in either order, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
`make check-runtime` exercises missing/invalid dependency metadata, unsafe paths/versions, unavailable and unloadable dylibs, omitted library families, and successful native loading from an isolated relocated copy of the bundled Frameworks. It is included in `make check`; no installed Homebrew files are modified.
`make check-bundle` copies the complete app to a path with spaces and exercises its symlinked CLI, native media reading, software AV1 availability, actual standalone extractor/Python/EJS and Deno with Homebrew, original app and dependency-cache access denied by the macOS sandbox. Removing a transitive bundled library must block launch with exit 69 without breaking help output. `Scripts.dependencies.self_check()` additionally checks archive traversal/symlink rejection with isolated files.
`make check-media` exercises actual playback through the app model, idempotent play/pause, immediate and rapid opposite playback intents, held-key suppression, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
Playback transition checks wait for native acknowledgement of rapid opposite intents, restore local bookmarks before autoplay, persist/resume the acknowledged position across close/reopen, and discard duration-exceeded bookmarks without preventing playback.
Native opening checks enter compact before playback acknowledges the request, then verify that anamorphic video metadata replaces the provisional ratio and that closing restores normal mode.
`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production startup/SwiftUI scene/delegate: missing-config defaults without creation, nested settings/key overrides, actual plain/modified shortcuts, rapid opposite intents and held-key suppression, same-window compact entry/restoration, removed old bindings, real text insertion without playback interception, native menu equivalents and Play/Pause selections, plain q insertion and queued ⌘Q quitting with text-input focus while draining final progress, malformed-file exit 78, cold/warm `--no-config`, unchanged file bytes and existing session settings, cold/default reuse, independent empty/local/YouTube launches, exactly one player window per PID, targeted media, retained original playback, help without launch, and warm default replacement. YouTube uses loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent and ignore mouse input, audio muted, and configuration/bundle/history isolated. Native menus receive their normal lazy-population/validation callbacks before direct selection. Finder/Space behavior, visible controls/menu presentation and Vorssaint discovery still need a check in the app.
Startup checks also exercise actual CLI-to-GUI reporting after the launcher exits: two independent private streams, canonical identities, acknowledged resume/seek/pause, quality reopenings, replacement/end/close/quit, failed resolution, local-media exclusions, and existing/dangling-destination preflight errors. Report readers tolerate duplicates and partial trailing lines; watched facts are derived from eligible native positions, not launcher success.
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.
Visual correctness, perceived A/V synchronization, and control usability still need a check in the visible app.
## Current limits
Local regular files, YouTube video URLs and resolved Miruro HTTP(S) sources, selectable YouTube resolutions, automatically selected audio/video tracks within the chosen quality, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. External resolved-source WebVTT is supported; embedded/YouTube captions, explicit codec/audio-track selection, VideoToolbox decoding and rotation/deinterlacing are not. Mid-track audio format changes report an error. The URL UI accepts YouTube videos; other resolved media uses `--source-file`.
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 inputs retain compressed packets within a shared 128 MiB budget, targeting sixty seconds ahead and thirty seconds behind for seekable media. Eviction can shorten history under byte pressure, and keyframe/audio-preroll requirements affect the usable rewind window. Decoded video/audio buffers remain separately bounded. Each network reader can hold one transient demux packet while backpressured. Separate inputs retain one authored timestamp origin and one playback clock; buffering stalls that clock rather than skipping ahead. Live/non-seekable sources do not promise a rewind window or a minute of future live media. Network reads/seeks have fifteen-second deadlines, including bounded reconnection for recorded range-seekable HTTP inputs; there is no automatic signed-URL refresh.
+34 -10
View File
@@ -238,6 +238,7 @@ enum CastWire {
final class CastSession { final class CastSession {
var onStatus: ((CastStatus) -> Void)? var onStatus: ((CastStatus) -> Void)?
var onDisconnect: ((String?) -> Void)? var onDisconnect: ((String?) -> Void)?
// Recoverable command errors; terminal channel failures belong to onDisconnect.
var onError: ((String) -> Void)? var onError: ((String) -> Void)?
var localHost: String? { var localHost: String? {
guard let endpoint = connection?.currentPath?.localEndpoint, guard let endpoint = connection?.currentPath?.localEndpoint,
@@ -254,6 +255,11 @@ final class CastSession {
let deadline: Task<Void, Never> let deadline: Task<Void, Never>
} }
private struct Write {
let data: Data
let seek: Int?
}
private let makeConnection: (CastDevice) -> NWConnection private let makeConnection: (CastDevice) -> NWConnection
private let requestTimeout: Duration private let requestTimeout: Duration
private let loadTimeout: Duration private let loadTimeout: Duration
@@ -269,7 +275,7 @@ final class CastSession {
private var awaitingPong = false private var awaitingPong = false
private var nextRequest = 0 private var nextRequest = 0
private var pending: [Int: Pending] = [:] private var pending: [Int: Pending] = [:]
private var writes: [Data] = [] private var writes: [Write] = []
private var writing = false private var writing = false
private var launchRequest: Int? private var launchRequest: Int?
private var appTransport: String? private var appTransport: String?
@@ -499,7 +505,7 @@ final class CastSession {
destination: receiver ? "receiver-0" : transport, destination: receiver ? "receiver-0" : transport,
operation: (command["type"] as? String ?? "command").lowercased(), operation: (command["type"] as? String ?? "command").lowercased(),
generation: mediaGeneration, id: id) { [weak self] _, error in generation: mediaGeneration, id: id) { [weak self] _, error in
guard let self else { return } guard let self, self.connection != nil else { return }
if self.pendingVolume?.request == id { self.pendingVolume = nil; self.publishSeekState() } if self.pendingVolume?.request == id { self.pendingVolume = nil; self.publishSeekState() }
if case .cancelled? = error { return } if case .cancelled? = error { return }
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() } if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
@@ -524,7 +530,17 @@ final class CastSession {
operation: String, generation: UUID? = nil, timeout: Duration? = nil, operation: String, generation: UUID? = nil, timeout: Duration? = nil,
id suppliedID: Int? = nil, id suppliedID: Int? = nil,
completion: @escaping ([String: Any]?, CastError?) -> Void) throws -> Int { completion: @escaping ([String: Any]?, CastError?) -> Void) throws -> Int {
guard pending.count < 32 else { throw CastError.queueFull } // Only the tail is replaceable: it has not reached the socket, and every
// intervening control or protocol frame remains an ordering barrier.
let superseded: Int?
if command["type"] as? String == "SEEK", namespace == CastWire.media,
let id = writes.last?.seek, let previous = pending[id],
previous.generation == generation, previous.destination == destination {
superseded = id
} else {
superseded = nil
}
guard pending.count < 32 || superseded != nil else { throw CastError.queueFull }
let id = suppliedID ?? allocateRequest() let id = suppliedID ?? allocateRequest()
var command = command var command = command
command["requestId"] = id command["requestId"] = id
@@ -536,8 +552,11 @@ final class CastSession {
} }
pending[id] = Pending(namespace: namespace, destination: destination, operation: operation, pending[id] = Pending(namespace: namespace, destination: destination, operation: operation,
generation: generation, completion: completion, deadline: deadline) generation: generation, completion: completion, deadline: deadline)
do { try send(command, namespace: namespace, destination: destination) } do {
try send(command, namespace: namespace, destination: destination, replacing: superseded)
}
catch { pending.removeValue(forKey: id)?.deadline.cancel(); throw error } catch { pending.removeValue(forKey: id)?.deadline.cancel(); throw error }
if let superseded { complete(superseded, json: nil, error: .cancelled) }
return id return id
} }
@@ -552,12 +571,17 @@ final class CastSession {
for id in ids { complete(id, json: nil, error: .cancelled) } for id in ids { complete(id, json: nil, error: .cancelled) }
} }
private func send(_ json: [String: Any], namespace: String, destination: String) throws { private func send(_ json: [String: Any], namespace: String, destination: String,
replacing: Int? = nil) throws {
guard connection != nil else { throw CastError.disconnected } guard connection != nil else { throw CastError.disconnected }
guard writes.count < 32 else { throw CastError.queueFull } guard writes.count < 32 || replacing != nil else { throw CastError.queueFull }
let payload = try JSONSerialization.data(withJSONObject: json) let payload = try JSONSerialization.data(withJSONObject: json)
writes.append(try CastWire.frame(.init(source: source, destination: destination, let write = Write(data: try CastWire.frame(.init(source: source, destination: destination,
namespace: namespace, payload: payload))) namespace: namespace, payload: payload)),
seek: namespace == CastWire.media && json["type"] as? String == "SEEK" ?
json["requestId"] as? Int : nil)
if replacing != nil { writes[writes.count - 1] = write }
else { writes.append(write) }
flush() flush()
} }
@@ -566,7 +590,7 @@ final class CastSession {
writing = true writing = true
let data = writes.removeFirst() let data = writes.removeFirst()
let id = connectionID let id = connectionID
socket.send(content: data, completion: .contentProcessed { [weak self] error in socket.send(content: data.data, completion: .contentProcessed { [weak self] error in
Task { @MainActor in Task { @MainActor in
guard let self, self.connectionID == id else { return } guard let self, self.connectionID == id else { return }
self.writing = false self.writing = false
@@ -760,7 +784,7 @@ final class CastSession {
namespace: CastWire.media, destination: transport, operation: "media status", namespace: CastWire.media, destination: transport, operation: "media status",
generation: self.mediaGeneration) { [weak self] _, error in generation: self.mediaGeneration) { [weak self] _, error in
if case .cancelled? = error { return } if case .cancelled? = error { return }
if let error { self?.onError?(error.localizedDescription) } if let self, self.connection != nil, let error { self.onError?(error.localizedDescription) }
} }
} }
} catch { self.fail(error as? CastError ?? .disconnected); return } } catch { self.fail(error as? CastError ?? .disconnected); return }
+307 -176
View File
@@ -29,17 +29,9 @@ final class PlayerModel: ObservableObject {
@Published private(set) var commandError: String? @Published private(set) var commandError: String?
private var transition = PlaybackTransition() private var transition = PlaybackTransition()
let castDiscovery = CastDiscovery() let castDiscovery = CastDiscovery()
@Published private(set) var castDevice: CastDevice? private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) }
@Published private(set) var castConnecting = false var castDevice: CastDevice? { casting.device }
private var castActive = false var castConnecting: Bool { casting.connecting }
private var castSession: CastSession?
private var castServer: CastMediaServer?
private var castTask: Task<Void, Never>?
private var castRequest: UUID?
private var castResumePlaying = false
private var castLocalVolume = 1.0
private var castLocalAudibleVolume = 1.0
private var castIntent: Bool?
private var currentMedia: MediaSource? private var currentMedia: MediaSource?
var pendingRevision: UInt64? { transition.pendingRevision } var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving } var resolvingYouTube: Bool { transition.isResolving }
@@ -69,7 +61,7 @@ final class PlayerModel: ObservableObject {
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil } guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress) return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
} }
var playbackIntent: Bool? { castActive ? castIntent : transition.playbackIntent } var playbackIntent: Bool? { casting.active ? casting.playbackIntent : transition.playbackIntent }
@Published private(set) var fullScreenIntent: Bool? @Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool? @Published private(set) var compactIntent: Bool?
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) } var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
@@ -162,7 +154,7 @@ final class PlayerModel: ObservableObject {
systemPlayback = SystemPlaybackControls(model: self) systemPlayback = SystemPlaybackControls(model: self)
} }
var isBusy: Bool { castConnecting || (!castActive && transition.isBusy(state.status)) } var isBusy: Bool { castConnecting || (!casting.active && transition.isBusy(state.status)) }
var canTogglePlayback: Bool { var canTogglePlayback: Bool {
handle != nil && !isBusy && handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED) (state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
@@ -200,167 +192,58 @@ final class PlayerModel: ObservableObject {
func cast(to device: CastDevice) { func cast(to device: CastDevice) {
guard canCast, let source = currentMedia, let handle else { return } guard canCast, let source = currentMedia, let handle else { return }
cancelYouTubeResolution() cancelYouTubeResolution()
let request = UUID()
let session = CastSession()
let server = CastMediaServer()
let localSnapshot = state
let playing = effectivePlaying
castResumePlaying = playing
castLocalVolume = volume
castLocalAudibleVolume = lastAudibleVolume
castRequest = request
castDevice = device
castConnecting = true
castSession = session
castServer = server
commandError = nil commandError = nil
casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle,
handle: handle, snapshot: state, playing: effectivePlaying,
volume: volume, audibleVolume: lastAudibleVolume)
revealControls() revealControls()
var initialStatus: CastStatus?
session.onStatus = { [weak self] status in
guard let self, self.castRequest == request else { return }
initialStatus = status
if self.castActive { self.receiveCastStatus(status) }
}
session.onDisconnect = { [weak self] message in
guard let self, self.castRequest == request else { return }
self.stopCasting()
self.commandError = message
}
session.onError = { [weak self] message in
guard let self, self.castRequest == request else { return }
if self.castSession?.playbackPending != true { self.castIntent = nil }
self.commandError = message
self.revealControls()
}
castTask = Task { [weak self] in
do {
try await session.connect(to: device)
try Task.checkCancellation()
guard player_pause(handle) else {
throw NSError(domain: "TerebiCast", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Could not pause local playback for casting."])
}
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while player_get_state(handle).status == PLAYER_PLAYING {
guard ContinuousClock.now < deadline else {
throw NSError(domain: "TerebiCast", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Local playback did not acknowledge the Cast handoff."])
}
try await Task.sleep(for: .milliseconds(20))
}
try Task.checkCancellation()
guard let self, self.castRequest == request else { return }
let snapshot = player_get_state(handle)
guard snapshot.status == PLAYER_PAUSED || snapshot.status == PLAYER_ENDED else {
throw NSError(domain: "TerebiCast", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Local playback is not ready to cast."])
}
let position = snapshot.can_seek && !source.live && snapshot.status != PLAYER_ENDED ? snapshot.position : 0
let media = try await server.start(input: self.castInput(source, duration: snapshot.duration),
receiverHost: device.host)
try Task.checkCancellation()
try await session.load(media, title: self.fileName ?? "terebi", position: position,
playing: playing, subtitle: self.selectedSubtitle)
try Task.checkCancellation()
guard self.castRequest == request else { return }
self.castActive = true
self.castConnecting = false
self.transition.acceptCast()
self.state = localSnapshot
self.state.buffered_start = 0
self.state.buffered_end = 0
self.subtitleTask?.cancel()
self.subtitleText = ""
self.castTask = nil
if let initialStatus { self.receiveCastStatus(initialStatus) }
self.systemPlayback?.update()
} catch {
guard let self, self.castRequest == request else { return }
self.stopCasting()
if !(error is CancellationError) { self.commandError = error.localizedDescription }
self.revealControls()
}
}
} }
func stopCasting(resume: Bool = true) { func stopCasting(resume: Bool = true) {
guard castDevice != nil else { return } casting.stop(resume: resume)
saveProgress() }
let source = currentMedia
let wasActive = castActive private func receiveCast(_ event: CastPlaybackTransition.Event) {
let position = state.can_seek && !isLiveStream && state.status != PLAYER_ENDED ? state.position : 0 objectWillChange.send()
let playing = wasActive ? effectivePlaying : castResumePlaying switch event {
if wasActive, let handle { case .changed:
transition.suspendHistory(revision: player_get_state(handle).revision) break
} case .activated:
castRequest = nil transition.acceptCast()
castTask?.cancel() subtitleTask?.cancel()
castTask = nil subtitleText = ""
castDevice = nil case .status(let projection):
castConnecting = false state = projection.state
castActive = false volume = projection.volume
castIntent = nil if volume > 0 { lastAudibleVolume = volume }
let session = castSession selectedSubtitle = projection.subtitle
castSession = nil if !isSeeking && !state.seek_pending { seekPosition = state.position }
session?.disconnect() if let handle { _ = player_report_remote_state(handle, &state) }
castServer?.stop() saveProgress()
castServer = nil case .stopped(let result):
volume = castLocalVolume if let checkpoint = result.checkpoint {
lastAudibleVolume = castLocalAudibleVolume saveCastProgress(checkpoint, source: result.source)
if let handle { _ = player_set_volume(handle, Float(volume)) } } else {
if resume, let source { saveProgress()
if wasActive {
openMedia(source, change: .restore(position: position, playing: playing))
} else if let handle {
_ = playing ? player_play(handle) : player_pause(handle)
} }
if let revision = result.suspendedRevision { transition.suspendHistory(revision: revision) }
volume = result.volume
lastAudibleVolume = result.audibleVolume
if let restore = result.restore {
openMedia(result.source, change: .restore(position: restore.position, playing: restore.playing))
}
revealControls()
case .error(let message):
commandError = message
revealControls()
} }
systemPlayback?.update() systemPlayback?.update()
revealControls()
}
private func castInput(_ source: MediaSource, duration: Double) -> CastMediaInput {
switch source {
case .file(let url):
return CastMediaInput(videoURL: url.path, videoHeaders: "", audioURL: nil, audioHeaders: nil,
local: true, restricted: false, live: false, duration: duration, subtitles: [])
case .youtube(let video, _):
return CastMediaInput(videoURL: video.video.url, videoHeaders: video.video.headers,
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
local: false, restricted: false, live: video.isLive,
duration: duration, subtitles: [])
case .resolved(let resolved):
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
live: false, duration: duration, subtitles: resolved.subtitles)
}
}
private func receiveCastStatus(_ status: CastStatus) {
guard castActive else { return }
state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED)
state.position = status.position
state.duration = status.duration > 0 ? status.duration : state.duration
state.buffering = status.buffering
state.can_seek = status.seekable && !isLiveStream
state.seek_pending = status.seekPending
state.buffered_start = 0
state.buffered_end = 0
state.revision &+= 1
volume = status.volume
if volume > 0 { lastAudibleVolume = volume }
selectedSubtitle = status.subtitle
if !status.playbackPending { castIntent = nil }
if !isSeeking && !status.seekPending { seekPosition = state.position }
if let handle { _ = player_report_remote_state(handle, &state) }
saveProgress()
systemPlayback?.update()
} }
@discardableResult @discardableResult
private func seekCast(to position: Double) -> Bool { private func seekCast(to position: Double) -> Bool {
let accepted = castSession?.seek(position) == true let accepted = casting.seek(position)
if accepted { state.seek_pending = true; seekPosition = position } if accepted { seekPosition = position }
commandError = accepted ? nil : "Could not seek Cast playback." commandError = accepted ? nil : "Could not seek Cast playback."
revealControls() revealControls()
return accepted return accepted
@@ -422,8 +305,8 @@ final class PlayerModel: ObservableObject {
} }
func selectSubtitle(_ index: Int?) { func selectSubtitle(_ index: Int?) {
if castActive { if casting.active {
if castSession?.setSubtitle(index) != true { commandError = "Could not change Cast subtitles." } if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." }
return return
} }
subtitleTask?.cancel() subtitleTask?.cancel()
@@ -656,10 +539,9 @@ final class PlayerModel: ObservableObject {
@discardableResult @discardableResult
func setPlayback(playing: Bool) -> Bool { func setPlayback(playing: Bool) -> Bool {
if castActive { if casting.active {
guard canTogglePlayback else { return false } guard canTogglePlayback else { return false }
let accepted = castSession?.setPlaying(playing) == true let accepted = casting.setPlaying(playing)
if accepted { castIntent = playing; objectWillChange.send() }
commandError = accepted ? nil : "Could not change Cast playback." commandError = accepted ? nil : "Could not change Cast playback."
revealControls() revealControls()
return accepted return accepted
@@ -686,7 +568,7 @@ final class PlayerModel: ObservableObject {
automaticQuality.resetHealth() automaticQuality.resetHealth()
isSeeking = false isSeeking = false
guard let handle, canSeek else { return } guard let handle, canSeek else { return }
if castActive { if casting.active {
seekCast(to: min(max(0, seekPosition), duration)) seekCast(to: min(max(0, seekPosition), duration))
return return
} }
@@ -733,7 +615,7 @@ final class PlayerModel: ObservableObject {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() } if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration) let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
let accepted = castActive ? seekCast(to: target) : transition.seek(target, handle: handle) let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
if accepted { seekPosition = target } if accepted { seekPosition = target }
commandError = accepted ? nil : "Could not queue the seek. Please try again." commandError = accepted ? nil : "Could not queue the seek. Please try again."
if !accepted { revealControls() } if !accepted { revealControls() }
@@ -750,8 +632,8 @@ final class PlayerModel: ObservableObject {
} }
func setVolume(_ value: Double) { func setVolume(_ value: Double) {
if castActive { if casting.active {
if value.isFinite, (0...1).contains(value), castSession?.setVolume(value) == true { if casting.setVolume(value) {
volume = value volume = value
if volume > 0 { lastAudibleVolume = volume } if volume > 0 { lastAudibleVolume = volume }
} }
@@ -1012,15 +894,19 @@ final class PlayerModel: ObservableObject {
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) { private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle else { return } guard let handle else { return }
if castActive { if let checkpoint = casting.checkpoint {
guard let url = currentMedia?.url, !isLiveStream, state.has_video, state.can_seek, saveCastProgress(checkpoint.state, source: checkpoint.source)
!state.seek_pending, state.duration > 0, state.position.isFinite else { return }
playbackProgress.save(state.status == PLAYER_ENDED ? nil : state.position, for: url)
} else { } else {
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress) transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
} }
} }
private func saveCastProgress(_ state: PlayerState, source: MediaSource) {
guard !source.live, state.has_video, state.can_seek, !state.seek_pending,
state.duration > 0, state.position.isFinite else { return }
playbackProgress.save(state.status == PLAYER_ENDED ? nil : state.position, for: source.url)
}
private func poll() { private func poll() {
guard let handle else { return } guard let handle else { return }
if reportingEnabled && !reportingErrorShown { if reportingEnabled && !reportingErrorShown {
@@ -1028,7 +914,7 @@ final class PlayerModel: ObservableObject {
showReportingError(reportingErrorMessage) showReportingError(reportingErrorMessage)
} }
} }
if !castActive { applyState(player_get_state(handle)) } if !casting.active { applyState(player_get_state(handle)) }
} }
func applyState(_ next: PlayerState) { func applyState(_ next: PlayerState) {
@@ -1109,6 +995,251 @@ final class PlayerModel: ObservableObject {
player_destroy(player) player_destroy(player)
} }
@MainActor
private final class CastPlaybackTransition {
struct Projection {
var state: PlayerState
var volume: Double
var subtitle: Int?
}
struct Return {
let source: MediaSource
let checkpoint: PlayerState?
let suspendedRevision: UInt64?
let volume: Double
let audibleVolume: Double
let restore: (position: Double, playing: Bool)?
}
enum Event {
case changed
case activated
case status(Projection)
case stopped(Return)
case error(String)
}
private enum HandoffError: LocalizedError {
case pause, acknowledgement, readiness
var errorDescription: String? {
switch self {
case .pause: "Could not pause local playback for casting."
case .acknowledgement: "Local playback did not acknowledge the Cast handoff."
case .readiness: "Local playback is not ready to cast."
}
}
}
@MainActor
private final class Operation {
let device: CastDevice
let source: MediaSource
let title: String
let subtitle: Int?
let handle: OpaquePointer
let snapshot: PlayerState
let playing: Bool
let volume: Double
let audibleVolume: Double
let session = CastSession()
let server = CastMediaServer()
var task: Task<Void, Never>?
var initialStatus: CastStatus?
var projection: Projection?
var intent: Bool?
init(device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
volume: Double, audibleVolume: Double) {
self.device = device
self.source = source
self.title = title
self.subtitle = subtitle
self.handle = handle
self.snapshot = snapshot
self.playing = playing
self.volume = volume
self.audibleVolume = audibleVolume
}
}
private var operation: Operation?
private let observe: (Event) -> Void
init(observe: @escaping (Event) -> Void) { self.observe = observe }
var device: CastDevice? { operation?.device }
var connecting: Bool { operation != nil && !active }
var active: Bool { operation?.projection != nil }
var playbackIntent: Bool? { operation?.intent }
var checkpoint: (source: MediaSource, state: PlayerState)? {
guard let operation, let projection = operation.projection else { return nil }
return (operation.source, projection.state)
}
func start(to device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
volume: Double, audibleVolume: Double) {
guard operation == nil else { return }
let operation = Operation(device: device, source: source, title: title, subtitle: subtitle,
handle: handle, snapshot: snapshot, playing: playing,
volume: volume, audibleVolume: audibleVolume)
self.operation = operation
operation.session.onStatus = { [weak self, weak operation] status in
guard let self, let operation, self.operation === operation else { return }
if operation.projection == nil { operation.initialStatus = status }
else { self.receive(status, operation: operation) }
}
operation.session.onDisconnect = { [weak self, weak operation] message in
guard let self, let operation, self.operation === operation else { return }
self.stop()
if let message { self.observe(.error(message)) }
}
operation.session.onError = { [weak self, weak operation] message in
guard let self, let operation, self.operation === operation else { return }
if !operation.session.playbackPending { operation.intent = nil }
self.observe(.error(message))
}
observe(.changed)
operation.task = Task { [weak self] in await self?.connect(operation) }
}
private func connect(_ operation: Operation) async {
do {
try check(operation)
try await operation.session.connect(to: operation.device)
try check(operation)
guard player_pause(operation.handle) else { throw HandoffError.pause }
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while player_get_state(operation.handle).status == PLAYER_PLAYING {
guard ContinuousClock.now < deadline else { throw HandoffError.acknowledgement }
try await Task.sleep(for: .milliseconds(20))
try check(operation)
}
let snapshot = player_get_state(operation.handle)
guard snapshot.status == PLAYER_PAUSED || snapshot.status == PLAYER_ENDED else {
throw HandoffError.readiness
}
let position = snapshot.can_seek && !operation.source.live && snapshot.status != PLAYER_ENDED ?
snapshot.position : 0
let media = try await operation.server.start(input: input(operation.source, duration: snapshot.duration),
receiverHost: operation.device.host)
try check(operation)
try await operation.session.load(media, title: operation.title, position: position,
playing: operation.playing, subtitle: operation.subtitle)
try check(operation)
var state = operation.snapshot
state.buffered_start = 0
state.buffered_end = 0
operation.projection = Projection(state: state, volume: operation.volume, subtitle: operation.subtitle)
operation.task = nil
observe(.activated)
if let status = operation.initialStatus {
operation.initialStatus = nil
receive(status, operation: operation)
} else if let projection = operation.projection {
observe(.status(projection))
}
} catch {
guard self.operation === operation else { return }
stop()
if !(error is CancellationError) { observe(.error(error.localizedDescription)) }
}
}
private func check(_ operation: Operation) throws {
try Task.checkCancellation()
guard self.operation === operation else { throw CancellationError() }
}
func stop(resume: Bool = true) {
guard let operation else { return }
let projection = operation.projection
let playing = operation.intent ?? projection.map { $0.state.status == PLAYER_PLAYING } ?? operation.playing
let position = projection.map {
$0.state.can_seek && !operation.source.live && $0.state.status != PLAYER_ENDED ? $0.state.position : 0
} ?? 0
let revision = projection == nil ? nil : player_get_state(operation.handle).revision
// Invalidate before teardown callbacks or any suspended task resumes. All native
// accesses are main-actor synchronous, with check() after every suspension:
// shutdown can destroy the player immediately after stop(resume: false).
self.operation = nil
operation.task?.cancel()
operation.task = nil
operation.session.onStatus = nil
operation.session.onDisconnect = nil
operation.session.onError = nil
operation.session.disconnect()
operation.server.stop()
_ = player_set_volume(operation.handle, Float(operation.volume))
if resume && projection == nil {
_ = operation.playing ? player_play(operation.handle) : player_pause(operation.handle)
}
observe(.stopped(Return(source: operation.source, checkpoint: projection?.state,
suspendedRevision: revision, volume: operation.volume,
audibleVolume: operation.audibleVolume,
restore: resume && projection != nil ? (position, playing) : nil)))
}
func setPlaying(_ playing: Bool) -> Bool {
guard let operation, operation.projection != nil, operation.session.setPlaying(playing) else { return false }
operation.intent = playing
observe(.changed)
return true
}
func seek(_ position: Double) -> Bool {
guard let operation, operation.projection != nil else { return false }
return operation.session.seek(position)
}
func setVolume(_ volume: Double) -> Bool {
guard let operation, operation.projection != nil else { return false }
return operation.session.setVolume(volume)
}
func setSubtitle(_ index: Int?) -> Bool {
guard let operation, operation.projection != nil else { return false }
return operation.session.setSubtitle(index)
}
private func receive(_ status: CastStatus, operation: Operation) {
guard var projection = operation.projection else { return }
projection.state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED)
projection.state.position = status.position
if status.duration > 0 { projection.state.duration = status.duration }
projection.state.buffering = status.buffering
projection.state.can_seek = status.seekable && !operation.source.live
projection.state.seek_pending = status.seekPending
projection.state.buffered_start = 0
projection.state.buffered_end = 0
projection.state.revision &+= 1
projection.volume = status.volume
projection.subtitle = status.subtitle
if !status.playbackPending { operation.intent = nil }
operation.projection = projection
observe(.status(projection))
}
private func input(_ source: MediaSource, duration: Double) -> CastMediaInput {
switch source {
case .file(let url):
return CastMediaInput(videoURL: url.path, videoHeaders: "", audioURL: nil, audioHeaders: nil,
local: true, restricted: false, live: false, duration: duration, subtitles: [])
case .youtube(let video, _):
return CastMediaInput(videoURL: video.video.url, videoHeaders: video.video.headers,
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
local: false, restricted: false, live: video.isLive,
duration: duration, subtitles: [])
case .resolved(let resolved):
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
live: false, duration: duration, subtitles: resolved.subtitles)
}
}
}
private enum MediaChange { private enum MediaChange {
case open case open
case quality(position: Double?, playing: Bool, automatic: Bool) case quality(position: Double?, playing: Bool, automatic: Bool)
+80 -31
View File
@@ -164,7 +164,9 @@ struct Input {
buffered->start(indices, offset(), true); buffered->start(indices, offset(), true);
} }
void seek(double seconds, double audioTarget = NAN) { void seek(double seconds, double audioTarget = NAN) {
const auto timestamp = int64_t((seconds + offset()) * AV_TIME_BASE); // Round forward so a sub-microsecond truncation cannot select the
// previous GOP when a playlist boundary is an exact stream timestamp.
const auto timestamp = int64_t(std::ceil((seconds + offset()) * AV_TIME_BASE));
if (buffered) { if (buffered) {
// Probing retains the opening packets. Do not reopen their HLS media // Probing retains the opening packets. Do not reopen their HLS media
// resource just to establish the initial zero-position cursors. // resource just to establish the initial zero-position cursors.
@@ -196,6 +198,15 @@ bool remuxAudio(const AVCodecParameters &codec) {
return codec.codec_id == AV_CODEC_ID_AAC && codec.ch_layout.nb_channels <= 2 && return codec.codec_id == AV_CODEC_ID_AAC && codec.ch_layout.nb_channels <= 2 &&
(codec.profile == AV_PROFILE_AAC_LOW || codec.profile == AV_PROFILE_AAC_HE || codec.profile == AV_PROFILE_AAC_HE_V2); (codec.profile == AV_PROFILE_AAC_LOW || codec.profile == AV_PROFILE_AAC_HE || codec.profile == AV_PROFILE_AAC_HE_V2);
} }
bool remuxVideo(Input &input, AVStream &stream) {
const auto &codec = *stream.codecpar;
const int profile = codec.profile & ~AV_PROFILE_H264_CONSTRAINED;
const auto rate = av_guess_frame_rate(input.format, &stream, nullptr);
return codec.codec_id == AV_CODEC_ID_H264 && codec.video_delay == 0 &&
(profile == AV_PROFILE_H264_BASELINE || profile == AV_PROFILE_H264_MAIN || profile == AV_PROFILE_H264_HIGH) &&
codec.level > 0 && codec.level <= 41 && codec.width > 0 && codec.width <= 1920 &&
codec.height > 0 && codec.height <= 1080 && rate.num > 0 && av_q2d(rate) <= 30.001;
}
struct Track { struct Track {
Input *input; Input *input;
AVStream *stream = nullptr; AVStream *stream = nullptr;
@@ -204,31 +215,33 @@ struct Track {
Frame decoded; Frame decoded;
bool eof = false, draining = false, remux = false; bool eof = false, draining = false, remux = false;
double nextTime = 0; double nextTime = 0;
Track(Input &in, int index, AVMediaType type, double start, bool live) : input(&in) { Track(Input &in, int index, AVMediaType type, double start, bool live, bool aligned = false) : input(&in) {
require(index >= 0); require(index >= 0);
stream = input->format->streams[index]; stream = input->format->streams[index];
remux = stream->codecpar->codec_id == (type == AVMEDIA_TYPE_VIDEO ? AV_CODEC_ID_H264 : AV_CODEC_ID_AAC); remux = type == AVMEDIA_TYPE_VIDEO ? remuxVideo(in, *stream) : remuxAudio(*stream->codecpar);
if (type == AVMEDIA_TYPE_VIDEO) {
const int profile = stream->codecpar->profile & ~AV_PROFILE_H264_CONSTRAINED;
auto rate = av_guess_frame_rate(input->format, stream, nullptr);
remux = remux && (profile == AV_PROFILE_H264_BASELINE || profile == AV_PROFILE_H264_MAIN || profile == AV_PROFILE_H264_HIGH) &&
stream->codecpar->level > 0 && stream->codecpar->level <= 41 &&
stream->codecpar->width <= 1920 && stream->codecpar->height <= 1080 &&
rate.num > 0 && av_q2d(rate) <= 30.001;
} else {
remux = remuxAudio(*stream->codecpar);
}
const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ? const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ?
std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) / std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) /
std::max(1, stream->codecpar->sample_rate)) : start; std::max(1, stream->codecpar->sample_rate)) : start;
if (!live && !input->buffered) input->seek(seekTarget); if (!live && !input->buffered) input->seek(seekTarget);
nextPacket(); nextPacket();
// Every VOD segment must start at its advertised time and on an IDR. If the // Indexed VOD can advertise source GOP boundaries; every actual start
// source GOP doesn't align, decode preroll and use VideoToolbox, not overlap. // still has to be an IDR. The opening frame may follow AAC priming.
if (type == AVMEDIA_TYPE_VIDEO) if (type == AVMEDIA_TYPE_VIDEO) {
remux = remux && !live && stream->codecpar->video_delay == 0 && pending && // Cached seeks can return the preceding GOP while read-ahead has
(pending->flags & AV_PKT_FLAG_KEY) && hasIDR(*pending, *stream->codecpar) && // reached this boundary but not yet delivered its IDR packet.
std::abs(packetTime() - start) < .001; if (aligned && remux)
while (pending && packetTime() < start - .001) nextPacket();
const auto rate = av_guess_frame_rate(input->format, stream, nullptr);
const double openingGap = rate.num > 0 ? av_q2d(av_inv_q(rate)) : 0;
remux = remux && !live && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
hasIDR(*pending, *stream->codecpar) &&
(std::abs(packetTime() - start) < .001 ||
(start == 0 && packetTime() >= 0 && packetTime() <= openingGap));
if (aligned && !remux) {
// A keyframe index is not proof of an IDR; recover full decoder preroll.
input->seek(0); eof = false; nextPacket();
}
}
if (!live && type == AVMEDIA_TYPE_VIDEO && pending && (pending->flags & AV_PKT_FLAG_KEY) && if (!live && type == AVMEDIA_TYPE_VIDEO && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
!hasIDR(*pending, *stream->codecpar) && stream->codecpar->codec_id == AV_CODEC_ID_H264) { !hasIDR(*pending, *stream->codecpar) && stream->codecpar->codec_id == AV_CODEC_ID_H264) {
// ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow. // ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow.
@@ -499,6 +512,7 @@ struct Pipeline {
bool live; bool live;
std::atomic<bool> &cancel; std::atomic<bool> &cancel;
double duration = 0; double duration = 0;
std::vector<double> boundaries;
Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) { Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) {
videoInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live); videoInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
duration = std::max(source.duration, videoInput->duration()); duration = std::max(source.duration, videoInput->duration());
@@ -519,9 +533,32 @@ struct Pipeline {
} }
} }
require(videoIndex >= 0 || audioIndex >= 0); require(videoIndex >= 0 || audioIndex >= 0);
// Read only a complete, already-loaded sample index, before read-ahead
// owns the demuxer. No media scan or upstream seeks to build a playlist.
if (!live && videoIndex >= 0) {
auto *stream = videoInput->format->streams[videoIndex];
const int count = avformat_index_get_entries_count(stream);
if (remuxVideo(*videoInput, *stream) && count > 0 && stream->nb_frames == count) {
boundaries.push_back(0);
double previous = -INFINITY;
for (int i = 0; i < count; ++i) {
const auto *entry = avformat_index_get_entry(stream, i);
const double time = entry->timestamp * av_q2d(stream->time_base) - videoInput->offset();
if (entry->timestamp == AV_NOPTS_VALUE || time < previous) { boundaries.clear(); break; }
previous = time;
if (!(entry->flags & AVINDEX_KEYFRAME) || time < boundaries.back() + segmentSeconds) continue;
// ponytail: cap indexed GOP segments at twelve seconds; longer GOPs use the fixed-grid transcode.
if (time - boundaries.back() > 2 * segmentSeconds || time >= duration) break;
boundaries.push_back(time);
}
if (boundaries.size() < 2 || duration - boundaries.back() > 2 * segmentSeconds ||
duration <= boundaries.back()) boundaries.clear();
else boundaries.push_back(duration);
}
}
videoInput->select({audioSource == videoInput.get() ? audioIndex : -1, videoIndex}, cacheBudget); videoInput->select({audioSource == videoInput.get() ? audioIndex : -1, videoIndex}, cacheBudget);
if (audioInput) audioInput->select({audioIndex, -1}, cacheBudget); if (audioInput) audioInput->select({audioIndex, -1}, cacheBudget);
reset(start); reset(start, !boundaries.empty());
if (!source.audio.empty()) require(audio && audio->stream); if (!source.audio.empty()) require(audio && audio->stream);
require(video || audio); require(video || audio);
if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14); if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14);
@@ -537,18 +574,18 @@ struct Pipeline {
videoInput->operationDeadline = std::numeric_limits<int64_t>::max(); videoInput->operationDeadline = std::numeric_limits<int64_t>::max();
if (audioInput) audioInput->operationDeadline = std::numeric_limits<int64_t>::max(); if (audioInput) audioInput->operationDeadline = std::numeric_limits<int64_t>::max();
} }
void reset(double start) { void reset(double start, bool aligned = false) {
armOperation(); armOperation();
videoOutput.reset(); audioOutput.reset(); videoOutput.reset(); audioOutput.reset();
video.reset(); audio.reset(); video.reset(); audio.reset();
// Fresh codecs retain independent IDRs/AAC priming. One recorded network // Fresh codecs retain independent IDRs/AAC priming. One recorded network
// demuxer atomically rewinds both packet lanes, seeking upstream only on misses. // demuxer atomically rewinds both packet lanes, seeking upstream only on misses.
const double audioTarget = audioIndex >= 0 && !remuxAudio(*audioSource->format->streams[audioIndex]->codecpar) ? const double audioTarget = audioIndex >= 0 && !remuxAudio(*audioSource->format->streams[audioIndex]->codecpar) ?
aacPreroll(start) : start; aacPreroll(start) : NAN;
if (videoInput->buffered) if (videoInput->buffered)
videoInput->seek(start, audioSource == videoInput.get() ? audioTarget : NAN); videoInput->seek(start, audioSource == videoInput.get() ? audioTarget : NAN);
if (audioInput && audioInput->buffered) audioInput->seek(start, audioTarget); if (audioInput && audioInput->buffered) audioInput->seek(start, audioTarget);
if (videoIndex >= 0) video = std::make_unique<Track>(*videoInput, videoIndex, AVMEDIA_TYPE_VIDEO, start, live); if (videoIndex >= 0) video = std::make_unique<Track>(*videoInput, videoIndex, AVMEDIA_TYPE_VIDEO, start, live, aligned);
if (audioIndex >= 0) audio = std::make_unique<Track>(*audioSource, audioIndex, AVMEDIA_TYPE_AUDIO, start, live); if (audioIndex >= 0) audio = std::make_unique<Track>(*audioSource, audioIndex, AVMEDIA_TYPE_AUDIO, start, live);
} }
Segment segment(int64_t id, double start, double end) { Segment segment(int64_t id, double start, double end) {
@@ -606,6 +643,7 @@ struct CastMedia {
std::thread producer; std::thread producer;
std::string base, token; std::string base, token;
double duration = 0; double duration = 0;
std::vector<double> boundaries;
std::deque<std::shared_ptr<const Segment>> segments; std::deque<std::shared_ptr<const Segment>> segments;
std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles; std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles;
#ifdef CAST_MEDIA_CHECK #ifdef CAST_MEDIA_CHECK
@@ -681,16 +719,26 @@ struct CastMedia {
} }
} catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); } } catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); }
} }
int64_t segmentCount() const {
return boundaries.empty() ? int64_t(std::ceil(duration / segmentSeconds)) : int64_t(boundaries.size() - 1);
}
double boundary(int64_t id) const {
return boundaries.empty() ? std::min(duration, id * segmentSeconds) : boundaries[size_t(id)];
}
std::vector<uint8_t> playlist() { std::vector<uint8_t> playlist() {
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n"; double longest = segmentSeconds;
int64_t first = 0, count = int64_t(std::ceil(duration / segmentSeconds)); for (size_t i = 1; i < boundaries.size(); ++i)
longest = std::max(longest, boundaries[i] - boundaries[i - 1]);
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:" +
std::to_string(int(std::ceil(longest))) + "\n";
int64_t first = 0, count = segmentCount();
std::lock_guard guard(lock); std::lock_guard guard(lock);
if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); } if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); }
else text += "#EXT-X-PLAYLIST-TYPE:VOD\n"; else text += "#EXT-X-PLAYLIST-TYPE:VOD\n";
text += "#EXT-X-MEDIA-SEQUENCE:" + std::to_string(first) + "\n"; text += "#EXT-X-MEDIA-SEQUENCE:" + std::to_string(first) + "\n";
if (source.live) text += "#EXT-X-DISCONTINUITY-SEQUENCE:" + std::to_string(first) + "\n"; if (source.live) text += "#EXT-X-DISCONTINUITY-SEQUENCE:" + std::to_string(first) + "\n";
for (int64_t id = first; id < first + count; ++id) { for (int64_t id = first; id < first + count; ++id) {
char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : std::min(segmentSeconds, duration - id * segmentSeconds); char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : boundary(id + 1) - boundary(id);
std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length); std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length);
// Each independently generated segment resets codec/mux state. // Each independently generated segment resets codec/mux state.
if (id > first) text += "#EXT-X-DISCONTINUITY\n"; if (id > first) text += "#EXT-X-DISCONTINUITY\n";
@@ -712,10 +760,10 @@ struct CastMedia {
require(!cancelled); require(!cancelled);
if (auto segment = cached()) return segment; if (auto segment = cached()) return segment;
try { try {
if (vodPipeline) vodPipeline->reset(id * segmentSeconds); const double start = boundary(id), end = boundary(id + 1);
else vodPipeline = std::make_unique<Pipeline>(source, cancelled, id * segmentSeconds); if (vodPipeline) vodPipeline->reset(start, !boundaries.empty());
auto segment = std::make_shared<const Segment>(vodPipeline->segment( else vodPipeline = std::make_unique<Pipeline>(source, cancelled, start);
id, id * segmentSeconds, std::min(duration, (id + 1) * segmentSeconds))); auto segment = std::make_shared<const Segment>(vodPipeline->segment(id, start, end));
{ std::lock_guard guard(lock); segments.push_back(segment); while (segments.size() > windowSegments) segments.pop_front(); } { std::lock_guard guard(lock); segments.push_back(segment); while (segments.size() > windowSegments) segments.pop_front(); }
return segment; return segment;
} catch (...) { } catch (...) {
@@ -798,7 +846,7 @@ struct CastMedia {
else if (source.live) { else if (source.live) {
std::lock_guard guard(lock); auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; }); std::lock_guard guard(lock); auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes); if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes);
} else if (double(id) * segmentSeconds >= duration) status = 404; } else if (id >= segmentCount()) status = 404;
else { else {
auto segment = vodSegment(id); auto segment = vodSegment(id);
retained = std::shared_ptr<const std::vector<uint8_t>>(segment, &segment->bytes); retained = std::shared_ptr<const std::vector<uint8_t>>(segment, &segment->bytes);
@@ -840,6 +888,7 @@ extern "C" bool cast_media_start(CastMedia *media, const char *host, char url[51
require(media && host && url && duration && !media->cancelled); require(media && host && url && duration && !media->cancelled);
auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0); auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0);
media->duration = pipeline->duration; media->duration = pipeline->duration;
media->boundaries = std::move(pipeline->boundaries);
media->bindReceiver(host); media->bindReceiver(host);
if (media->source.live) { if (media->source.live) {
media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); }); media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); });
+7 -2
View File
@@ -234,8 +234,13 @@ struct Player {
return true; return true;
} }
} }
if (commands.size() >= commandLimit) return false; if (command.action == Action::Seek && !commands.empty() &&
commands.push_back(std::move(command)); commands.back().action == Action::Seek && commands.back().epoch == command.epoch) {
commands.back().seconds = command.seconds;
} else {
if (commands.size() >= commandLimit) return false;
commands.push_back(std::move(command));
}
if (newTimeline) requestedEpoch.store(commands.back().epoch, std::memory_order_relaxed); if (newTimeline) requestedEpoch.store(commands.back().epoch, std::memory_order_relaxed);
if (commands.back().action == Action::Open) { if (commands.back().action == Action::Open) {
const auto revision = state.revision + 1; const auto revision = state.revision + 1;
+11 -17
View File
@@ -1,22 +1,16 @@
- [x] add miruro streaming support - [x] fix the readme:
- [x] investigate and fix audio when streaming from miruro - audio seems to crackle (not for youtube i think; only for miruros) - clear out redundant and irrelevant overly detailed crap from the readme.
- [x] add chrome cast support — implemented; verify playback, captions and A/V sync on a physical Cast device - we don't need every minute detail (already described perfectly by the code btw) described in painstakingly verbose paragraphs in the readme.
- [x] fix premature receiver-launch timeout: reports failure even though Chromecast subsequently connects and playback works - implementation details are not appropriate in a readme file (without a very good reason to be there).
- [x] fix TV volume slider jitter: receiver updates appear to compete with local volume adjustments - the readme should be compressed to actually useful information - nothing more - and verbose, irrelevant, or redundant information deleted.
- [x] investigate and reduce slow buffering after seeking in YouTube videos while casting, compared with local playback — reuse probed inputs and cache completed segments; confirm perceived improvement on TV
- [x] fix Miruro Cast upstream overfetch: select HLS streams and share bounded video/audio packet buffering — verify sustained playback on TV
- [x] styling/ui changes — implemented; verify layout and casting appearance in the app:
- move the captions button in the controls left of the compact mode icon
- place the Cast button between pin and settings in the top-right container; in compact mode, between exit compact mode and settings
- don't leave and empty spot where the captions were supposed to be if captions aren't available; buttons should be moved over in that case.
- the background is currently black in the player when casting (chrome cast), but we should follow the design language of the rest of the app.
- replace the current chrome cast icon with the apple tv casting icon.
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)
- [ ] evaluate playback optimization opportunities for YouTube and Miruro — measure benefits before changing behavior - [ ] evaluate playback optimization opportunities for YouTube and Miruro — measure benefits before changing behavior
- [ ] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering - [x] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering
- [ ] expand Cast H.264/AAC remux coverage with keyframe-aligned segments; preserve seek accuracy, timestamps and receiver compatibility - [x] expand Cast H.264/AAC remux coverage with keyframe-aligned segments; preserve seek accuracy, timestamps and receiver compatibility
- [ ] reuse decoder/encoder state during sequential Cast playback; reset on random seeks and preserve independent keyframes and AAC continuity - [ ] reuse decoder/encoder state during sequential Cast playback; reset on random seeks and preserve independent keyframes and AAC continuity
- [ ] evaluate VideoToolbox hardware video decoding for native and Cast playback; retain software fallback for unsupported formats - [ ] evaluate VideoToolbox hardware video decoding for native and Cast playback; retain software fallback for unsupported formats
- [ ] benchmark cold start, cached/uncached seeks and sustained playback separately to prioritize the actual bottlenecks - [ ] benchmark cold start, cached/uncached seeks and sustained playback separately to prioritize the actual bottlenecks
- do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs - do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs
- [ ] add playlist support
- maybe by allowing multiple file selection? 🤔
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)