feat: add Chromecast playback support

This commit is contained in:
2026-10-13 17:31:37 +02:00
parent 74b4bfe6c1
commit bb777b0ecd
22 changed files with 3299 additions and 115 deletions
+325
View File
@@ -0,0 +1,325 @@
#include "cast_media.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavcodec/bsf.h>
#include <libavformat/avformat.h>
#include <libavutil/opt.h>
}
#include <algorithm>
#include <atomic>
#include <cassert>
#include <cctype>
#include <cstdlib>
#include <cmath>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <map>
#include <string>
#include <thread>
#include <vector>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>
#include <poll.h>
static void ok(int result) { assert(result >= 0); }
static std::vector<uint8_t> readFile(const std::string &path) {
std::ifstream file(path, std::ios::binary); assert(file.good());
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
}
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());
}
// Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures.
static void fixture(const std::string &path) {
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 *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio);
video->width = 96; video->height = 64; video->pix_fmt = AV_PIX_FMT_NV12;
video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = 300000; video->gop_size = 70; video->max_b_frames = 0;
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);
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);
ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr));
auto *vs = avformat_new_stream(format, nullptr), *as = avformat_new_stream(format, nullptr); assert(vs && as);
vs->time_base = video->time_base; as->time_base = audio->time_base;
ok(avcodec_parameters_from_context(vs->codecpar, video)); ok(avcodec_parameters_from_context(as->codecpar, audio));
AVBSFContext *aspect = nullptr;
ok(av_bsf_alloc(av_bsf_get_by_name("h264_metadata"), &aspect));
ok(avcodec_parameters_copy(aspect->par_in, vs->codecpar));
aspect->time_base_in = video->time_base;
ok(av_opt_set(aspect->priv_data, "sample_aspect_ratio", "4/3", 0));
ok(av_bsf_init(aspect));
ok(avio_open(&format->pb, path.c_str(), AVIO_FLAG_WRITE)); ok(avformat_write_header(format, nullptr));
auto encode = [&](AVCodecContext *codec, AVStream *stream, AVFrame *frame) {
ok(avcodec_send_frame(codec, frame)); auto *packet = av_packet_alloc(); assert(packet);
for (;;) {
int result = avcodec_receive_packet(codec, packet);
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break;
ok(result);
if (codec == video) {
ok(av_bsf_send_packet(aspect, packet));
while (av_bsf_receive_packet(aspect, packet) >= 0) {
av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index;
ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet);
}
} else {
av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index;
ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet);
}
}
av_packet_free(&packet);
};
int64_t vi = 0, ai = 0;
while (vi < 900 || ai < 90 * 48000) {
bool isVideo = vi < 900 && (ai >= 90 * 48000 || vi / 10.0 <= ai / 48000.0);
auto *f = av_frame_alloc(); assert(f);
if (isVideo) {
f->width = 96; f->height = 64; f->format = video->pix_fmt; f->pts = vi++;
ok(av_frame_get_buffer(f, 32));
for (int y = 0; y < 64; ++y) std::memset(f->data[0] + y * f->linesize[0], 40 + vi % 160, 96);
for (int y = 0; y < 32; ++y) std::memset(f->data[1] + y * f->linesize[1], 128, 96);
encode(video, vs, f);
} else {
f->sample_rate = 48000; f->format = audio->sample_fmt; f->nb_samples = audio->frame_size;
ok(av_channel_layout_copy(&f->ch_layout, &audio->ch_layout)); f->pts = ai;
ok(av_frame_get_buffer(f, 0));
for (int channel = 0; channel < 2; ++channel) for (int n = 0; n < f->nb_samples; ++n)
reinterpret_cast<float *>(f->data[channel])[n] = float(.1 * std::sin((ai + n) * 440 * 2 * 3.141592653589793 / 48000));
ai += f->nb_samples; encode(audio, as, f);
}
av_frame_free(&f);
}
encode(video, vs, nullptr); encode(audio, as, nullptr); ok(av_write_trailer(format));
av_bsf_free(&aspect);
avio_closep(&format->pb); avformat_free_context(format); avcodec_free_context(&video); avcodec_free_context(&audio);
}
static std::vector<uint8_t> wav() {
std::vector<uint8_t> bytes(44 + 90 * 48000 * 2);
auto put = [&](size_t at, uint32_t value, int length) { for (int i = 0; i < length; ++i) bytes[at + i] = uint8_t(value >> (8 * i)); };
std::memcpy(bytes.data(), "RIFF", 4); put(4, uint32_t(bytes.size() - 8), 4); std::memcpy(bytes.data() + 8, "WAVEfmt ", 8);
put(16, 16, 4); put(20, 1, 2); put(22, 1, 2); put(24, 48000, 4); put(28, 96000, 4); put(32, 2, 2); put(34, 16, 2);
std::memcpy(bytes.data() + 36, "data", 4); put(40, uint32_t(bytes.size() - 44), 4);
for (size_t i = 0; i < 90 * 48000; ++i) put(44 + i * 2, uint16_t(int16_t(4000 * std::sin(i * 440 * 2 * 3.141592653589793 / 48000))), 2);
return bytes;
}
struct HTTP {
int status = 0; std::string headers; std::vector<uint8_t> bytes;
};
static HTTP request(const std::string &url, const std::string &method = "GET", const std::string &headers = "") {
assert(url.starts_with("http://127.0.0.1:")); size_t slash = url.find('/', 7), colon = url.find(':', 7);
sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); address.sin_port = htons(std::stoi(url.substr(colon + 1, slash - colon - 1)));
int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(socket >= 0);
timeval timeout{60, 0}; setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
assert(connect(socket, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0);
std::string text = method + " " + url.substr(slash) + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + headers + "Connection: close\r\n\r\n";
assert(send(socket, text.data(), text.size(), 0) == ssize_t(text.size()));
std::vector<uint8_t> bytes; uint8_t buffer[65536];
for (;;) { ssize_t n = recv(socket, buffer, sizeof(buffer), 0); assert(n >= 0); if (!n) break; bytes.insert(bytes.end(), buffer, buffer + n); }
close(socket); std::string raw(bytes.begin(), bytes.end()); size_t end = raw.find("\r\n\r\n"); assert(end != std::string::npos);
return {std::stoi(raw.substr(9, 3)), raw.substr(0, end + 4), {bytes.begin() + end + 4, bytes.end()}};
}
// A tiny real upstream tests resource-specific private headers and byte-range seek.
struct Upstream {
int listener; uint16_t port; std::thread threads[4]; std::atomic<bool> stopped{false};
std::atomic<int> foreignRequests{0}, videoRequests{0}, audioRequests{0};
std::map<std::string, std::vector<uint8_t>> resources;
std::string foreign;
explicit Upstream(std::vector<uint8_t> video, std::vector<uint8_t> audio) {
resources["/source.ts"] = std::move(video); resources["/audio.wav"] = std::move(audio);
resources["/public.ts"] = resources["/source.ts"];
listener = socket(AF_INET, SOCK_STREAM, 0); assert(listener >= 0); int one = 1; setsockopt(listener, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
assert(bind(listener, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0); assert(listen(listener, 16) == 0);
assert(fcntl(listener, F_SETFL, O_NONBLOCK) == 0);
socklen_t length = sizeof(address); assert(getsockname(listener, reinterpret_cast<sockaddr *>(&address), &length) == 0); port = ntohs(address.sin_port);
}
std::string url(std::string path) const { return "http://127.0.0.1:" + std::to_string(port) + path; }
void start() { for (auto &thread : threads) thread = std::thread([&] {
while (!stopped) {
pollfd ready{listener, POLLIN, 0};
if (poll(&ready, 1, 100) <= 0 || !(ready.revents & POLLIN)) continue;
int client = accept(listener, nullptr, nullptr); if (client < 0) continue;
assert(fcntl(client, F_SETFL, fcntl(client, F_GETFL) & ~O_NONBLOCK) == 0);
int one = 1; setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); timeval timeout{5, 0}; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
std::string request; char buffer[2048];
while (request.find("\r\n\r\n") == std::string::npos && request.size() < 16384) { auto n = recv(client, buffer, sizeof(buffer), 0); if (n <= 0) break; request.append(buffer, size_t(n)); }
size_t begin = request.find(' '), end = request.find(' ', begin + 1); std::string path = request.substr(begin + 1, end - begin - 1);
int status = 200; std::string extra; std::vector<uint8_t> data;
if (path == "/redirect") { status = 302; extra = "Location: " + foreign + "/source.ts\r\n"; }
else if (path == "/cross.m3u8" || path == "/index.m3u8" || path == "/public-cross.m3u8") {
std::string playlist = "#EXTM3U\n#EXT-X-TARGETDURATION:90\n#EXTINF:90,\n" +
(path == "/index.m3u8" ? url("") : foreign) +
(path == "/public-cross.m3u8" ? "/public.ts" : "/source.ts") + "\n#EXT-X-ENDLIST\n";
data.assign(playlist.begin(), playlist.end());
} else if (auto resource = resources.find(path); resource != resources.end()) {
bool audio = path == "/audio.wav";
bool publicMedia = path == "/public.ts";
bool authorized = publicMedia || request.find(audio ? "X-Audio: audio-secret" : "X-Video: video-secret") != std::string::npos;
bool wrong = request.find(publicMedia ? "secret" : (audio ? "video-secret" : "audio-secret")) != std::string::npos;
if (!authorized || wrong) { ++foreignRequests; status = 403; }
else { if (audio) ++audioRequests; else ++videoRequests; data = resource->second; }
} else status = 404;
size_t low = 0, high = data.size();
auto range = request.find("Range: bytes=");
if (range != std::string::npos && !data.empty()) {
low = std::stoull(request.substr(range + 13)); auto dash = request.find('-', range);
if (dash != std::string::npos && std::isdigit(request[dash + 1])) high = std::min(high, size_t(std::stoull(request.substr(dash + 1))) + 1);
if (low >= high) { status = 416; low = high = 0; }
else { status = 206; extra += "Content-Range: bytes " + std::to_string(low) + "-" + std::to_string(high - 1) + "/" + std::to_string(data.size()) + "\r\n"; }
}
std::string header = "HTTP/1.1 " + std::to_string(status) + " Test\r\nConnection: close\r\nAccept-Ranges: bytes\r\n" + extra + "Content-Length: " + std::to_string(high - low) + "\r\n\r\n";
send(client, header.data(), header.size(), 0);
if (!request.starts_with("HEAD")) while (high > low) { auto n = send(client, data.data() + low, high - low, 0); if (n <= 0) break; low += size_t(n); }
close(client);
}
}); }
~Upstream() { stopped = true; shutdown(listener, SHUT_RDWR); for (auto &thread : threads) if (thread.joinable()) thread.join(); close(listener); }
};
static void playable(const HTTP &response, const std::string &path, bool video, double minimumTime) {
assert(response.status == 200 && response.bytes.size() > 188 && response.bytes[0] == 0x47); writeFile(path, response.bytes);
AVFormatContext *format = nullptr; ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr)); ok(avformat_find_stream_info(format, nullptr));
int audioIndex = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(audioIndex >= 0);
int videoIndex = av_find_best_stream(format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); assert(!video || videoIndex >= 0);
std::map<int, AVCodecContext *> codecs; std::map<int, int> frames;
for (int index : {audioIndex, videoIndex}) if (index >= 0) {
auto *stream = format->streams[index]; auto *codec = avcodec_alloc_context3(avcodec_find_decoder(stream->codecpar->codec_id)); assert(codec);
ok(avcodec_parameters_to_context(codec, stream->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr)); codecs[index] = codec;
}
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc(); bool timestamp = false;
while (av_read_frame(format, packet) >= 0) {
auto found = codecs.find(packet->stream_index);
if (found != codecs.end()) {
if (packet->pts != AV_NOPTS_VALUE) { double time = packet->pts * av_q2d(format->streams[packet->stream_index]->time_base); assert(time >= minimumTime - .1); timestamp = true; }
ok(avcodec_send_packet(found->second, packet));
while (avcodec_receive_frame(found->second, frame) >= 0) {
if (packet->stream_index == videoIndex) {
auto sar = av_guess_sample_aspect_ratio(format, format->streams[videoIndex], frame);
if (sar.num <= 0 || sar.den <= 0) sar = {1, 1};
assert(sar.num > 0 && std::abs(double(frame->width) / frame->height * av_q2d(sar) - 2.0) < .01);
}
++frames[packet->stream_index]; av_frame_unref(frame);
}
}
av_packet_unref(packet);
}
assert(timestamp && frames[audioIndex] > 10 && (!video || frames[videoIndex] > 10));
for (auto &[index, codec] : codecs) avcodec_free_context(&codec);
av_packet_free(&packet); av_frame_free(&frame); avformat_close_input(&format);
}
static std::pair<double, double> audioSpan(const HTTP &response, const std::string &path) {
assert(response.status == 200);
writeFile(path, response.bytes);
AVFormatContext *format = nullptr;
ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr));
ok(avformat_find_stream_info(format, nullptr));
int index = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(index >= 0);
auto *codec = avcodec_alloc_context3(avcodec_find_decoder(format->streams[index]->codecpar->codec_id)); assert(codec);
ok(avcodec_parameters_to_context(codec, format->streams[index]->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr));
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc();
double first = INFINITY, end = -INFINITY;
while (av_read_frame(format, packet) >= 0) {
if (packet->stream_index == index) {
ok(avcodec_send_packet(codec, packet));
while (avcodec_receive_frame(codec, frame) >= 0) {
assert(frame->best_effort_timestamp != AV_NOPTS_VALUE);
double position = frame->best_effort_timestamp * av_q2d(format->streams[index]->time_base);
first = std::min(first, position);
end = position + double(frame->nb_samples) / frame->sample_rate;
av_frame_unref(frame);
}
}
av_packet_unref(packet);
}
av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&codec); avformat_close_input(&format);
assert(std::isfinite(first) && end > first);
return {first, end};
}
static CastMedia *start(CastMediaSource source, std::string &url, double &duration) {
char error[512]{}, bytes[512]{}; auto *media = cast_media_create(&source, error); assert(media);
assert(cast_media_start(media, "127.0.0.1", bytes, &duration, error)); url = bytes; return media;
}
int main(int argc, char **argv) {
bool liveCheck = false;
for (int argument = 1; argument < argc; ++argument) if (std::string(argv[argument]) == "--live") liveCheck = true;
char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp);
const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string();
fixture(video); writeFile(audio, wav());
auto videoBytes = readFile(video), audioBytes = readFile(audio);
Upstream upstream(videoBytes, audioBytes), foreign(videoBytes, audioBytes);
upstream.foreign = foreign.url(""); upstream.start(); foreign.start();
std::string url; double duration = 0;
CastMediaSource local{video.c_str(), "", nullptr, nullptr, true, false, false, 0};
auto *media = start(local, url, duration); assert(duration >= 89 && duration <= 92);
std::string base = url.substr(0, url.rfind('/') + 1);
auto playlist = request(url); assert(playlist.status == 200); std::string text(playlist.bytes.begin(), playlist.bytes.end()); assert(text.find("#EXT-X-ENDLIST") != std::string::npos);
playable(request(base + "segment/10.ts"), segment, true, 60);
playable(request(base + "segment/0.ts"), segment, true, 0);
assert(request(base + "../source.ts").status == 404);
assert(request(base + "%2e%2e/source.ts").status == 404);
assert(request(url.substr(0, url.find('/', 7)) + "/index.m3u8").status == 404);
assert(request(base + "segment/999999.ts").status == 404);
const char vtt[] = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n"; char subtitle[512]{};
assert(cast_media_subtitle(media, 1, vtt, sizeof(vtt) - 1, subtitle));
auto subtitles = request(subtitle); assert(subtitles.status == 200 && subtitles.headers.find("Access-Control-Allow-Origin: *") != std::string::npos);
auto ranged = request(subtitle, "GET", "Range: bytes=0-5\r\n"); assert(ranged.status == 206 && ranged.bytes.size() == 6);
assert(request(subtitle, "HEAD").bytes.empty()); assert(request(subtitle, "OPTIONS").status == 204);
assert(request(subtitle, "GET", "Range: bytes=99999-\r\n").status == 416);
cast_media_cancel(media); cast_media_destroy(media);
// Previously published capabilities expire, including the bound listening socket.
auto authority = url.substr(7, url.find('/', 7) - 7); auto colon = authority.find(':'); sockaddr_in closed{};
closed.sin_family = AF_INET; closed.sin_addr.s_addr = htonl(INADDR_LOOPBACK); closed.sin_port = htons(std::stoi(authority.substr(colon + 1)));
int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(connect(socket, reinterpret_cast<sockaddr *>(&closed), sizeof(closed)) != 0); close(socket);
std::string hls = upstream.url("/index.m3u8"); CastMediaSource miruro{hls.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90};
media = start(miruro, url, duration); base = url.substr(0, url.rfind('/') + 1);
playable(request(base + "segment/10.ts"), segment, true, 60); cast_media_destroy(media);
std::string networkVideo = upstream.url("/source.ts"), networkAudio = upstream.url("/audio.wav");
CastMediaSource youtube{networkVideo.c_str(), "X-Video: video-secret\r\n", networkAudio.c_str(), "X-Audio: audio-secret\r\n", false, false, false, 90};
media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1);
playable(request(base + "segment/10.ts"), segment, true, 60);
auto vodFirstAudio = audioSpan(request(base + "segment/0.ts"), segment);
auto vodSecondAudio = audioSpan(request(base + "segment/1.ts"), segment);
assert(std::abs(vodSecondAudio.first - vodFirstAudio.second) < 1.0 / 48000);
cast_media_destroy(media);
assert(upstream.videoRequests > 0 && upstream.audioRequests > 0 && upstream.foreignRequests == 0);
CastMediaSource audioOnly{audio.c_str(), "", nullptr, nullptr, true, false, false, 0};
media = start(audioOnly, url, duration); base = url.substr(0, url.rfind('/') + 1); playable(request(base + "segment/10.ts"), segment, false, 60); cast_media_destroy(media);
for (const auto &path : {"/redirect", "/cross.m3u8"}) {
auto resource = upstream.url(path); CastMediaSource unsafe{resource.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90};
char error[512]{}, result[512]{}; media = cast_media_create(&unsafe, error); assert(media);
assert(!cast_media_start(media, "127.0.0.1", result, &duration, error)); assert(std::string(error).find("secret") == std::string::npos); cast_media_destroy(media);
}
assert(foreign.foreignRequests == 0 && foreign.videoRequests == 0);
auto publicManifest = upstream.url("/public-cross.m3u8");
CastMediaSource publicHLS{publicManifest.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, false, false, 90};
media = start(publicHLS, url, duration); base = url.substr(0, url.rfind('/') + 1);
playable(request(base + "segment/0.ts"), segment, true, 0); cast_media_destroy(media);
assert(foreign.videoRequests > 0 && foreign.foreignRequests == 0);
if (liveCheck) {
youtube.live = true; media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1); assert(duration == 0);
auto firstAudio = audioSpan(request(base + "segment/0.ts"), segment);
auto secondAudio = audioSpan(request(base + "segment/1.ts"), segment);
assert(std::abs(secondAudio.first - firstAudio.second) < 1.0 / 48000);
#ifdef CAST_MEDIA_CHECK
assert(cast_media_check_advance(media, 54));
#else
assert(false && "Compile the permanent live check with CAST_MEDIA_CHECK");
#endif
auto response = request(url); assert(response.status == 200); std::string text(response.bytes.begin(), response.bytes.end());
auto at = text.find("#EXT-X-MEDIA-SEQUENCE:"); assert(at != std::string::npos);
int64_t sequence = std::stoll(text.substr(at + 22));
size_t count = 0, cursor = 0;
while ((cursor = text.find("#EXTINF:", cursor)) != std::string::npos) { ++count; cursor += 8; }
assert(count == 8 && text.find("#EXT-X-ENDLIST") == std::string::npos);
int64_t last = sequence + int64_t(count) - 1;
assert(sequence > 0 && request(base + "segment/0.ts").status == 404);
playable(request(base + "segment/" + std::to_string(last) + ".ts"), segment, true, 0); cast_media_destroy(media);
}
std::filesystem::remove_all(directory);
std::cout << "cast-media: playable A/V, VOD random seek, separate audio, HLS origin/header policy, subtitles, ranges, capabilities, cancellation" << (liveCheck ? ", bounded live window" : " (use --live for rolling window)") << " passed\n";
}
+69
View File
@@ -0,0 +1,69 @@
import AppKit
@main
@MainActor
struct CastPlaybackCheck {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
// A real I/O watchdog, not a clock used by the playback behavior under test.
static func until(_ model: PlayerModel, _ message: String, permitError: Bool = false, _ predicate: () -> Bool) async throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
while !predicate(), ContinuousClock.now < deadline {
if !permitError, let error = model.commandError { fatalError("FAIL: " + error) }
try await Task.sleep(for: .milliseconds(20))
}
require(predicate(), message)
}
static func main() async throws {
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
let source = URL(fileURLWithPath: CommandLine.arguments[1])
let port = UInt16(CommandLine.arguments[2])!
let suite = "terebi.cast-playback-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults)
defer { model.shutdown() }
model.setVolume(0)
model.open(source)
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
require(model.setPlayback(playing: false), "local pause accepted")
try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
try await until(model, "receiver load acknowledges paused handoff") {
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
}
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
require(model.setPlayback(playing: true), "remote play accepted")
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")
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")
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
model.stopCasting()
try await until(model, "local decoder restores receiver position paused") {
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
}
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
"remote history is not overwritten by the abandoned local position")
require(model.volume == 0, "local volume survives receiver volume changes")
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
try await until(model, "local resume acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
model.cast(to: CastDevice(id: "invalid", name: "Unavailable TV", host: "127.0.0.1", port: 0))
try await until(model, "failed Cast connection restores local playback", permitError: true) {
model.castDevice == nil && model.commandError != nil && model.state.status == PLAYER_PLAYING
}
require(model.canSeek && model.state.position >= 24, "connection failure preserves a usable local timeline")
model.closeFile()
try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY }
print("PASS: real local decode → TLS Cast LOAD/play/seek/pause → local restoration/history; failed connection recovery")
}
}
+636
View File
@@ -0,0 +1,636 @@
import Foundation
@preconcurrency import Network
import Security
#if CAST_SESSION_CHECKS
struct CastTextTrack: Sendable {
let id: Int
let url: URL
let language: String
let label: String
}
struct CastMediaDescription: Sendable {
let url: URL
let contentType: String
let duration: Double
let live: Bool
let tracks: [CastTextTrack]
}
#endif
private func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
@MainActor
private func until(_ message: String, attempts: Int = 500, _ predicate: () -> Bool) async throws {
// An I/O watchdog, not a wall-clock date used to decide playback behavior.
for _ in 0..<attempts {
if predicate() { return }
try await Task.sleep(for: .milliseconds(10))
}
require(predicate(), message)
}
@MainActor
private final class CastCheckClock {
private struct Waiter {
let delay: Duration
let deadline: Duration
let continuation: CheckedContinuation<Void, any Error>
}
private var now: Duration = .zero
private var waiters: [UUID: Waiter] = [:]
var pendingCount: Int { waiters.count }
func hasDelay(_ delay: Duration) -> Bool { waiters.values.contains { $0.delay == delay } }
func sleep(_ delay: Duration) async throws {
try Task.checkCancellation()
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
waiters[id] = Waiter(delay: delay, deadline: now + delay, continuation: continuation)
}
} onCancel: {
Task { @MainActor [weak self] in
self?.waiters.removeValue(forKey: id)?.continuation.resume(throwing: CancellationError())
}
}
try Task.checkCancellation()
}
func advance(_ duration: Duration) {
now += duration
let due = waiters.filter { $0.value.deadline <= now }.map(\.key)
for id in due { waiters.removeValue(forKey: id)?.continuation.resume() }
}
}
@MainActor
private final class Receiver {
private let listener: NWListener
private var socket: NWConnection?
private var stream = CastWire.Stream()
private var sender = ""
private let transport = "receiver-app"
private var media: [String: Any] = [:]
private var session = 1
private var position = 0.0
private var playing = false
private var activeTracks: [Int] = []
var commands: [(String, [String: Any])] = []
var rejectLoad = false
var rejectLaunch = false
var dropLaunch = false
var dropLoad = false
var dropCommands = false
var rejectCommand = false
var holdSeek = false
var ignoreHeartbeat = false
var verifyRelay = false
var relayVerified = false
var failure: String?
private var heldSeek: [String: Any]?
private var started = false
private var volume = 1.0
var muted = false
var port: UInt16 { listener.port!.rawValue }
var device: CastDevice { .init(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port) }
init(identity: SecIdentity) throws {
let tls = NWProtocolTLS.Options()
guard let identity = sec_identity_create(identity) else { throw CastError.protocolError }
sec_protocol_options_set_local_identity(tls.securityProtocolOptions, identity)
sec_protocol_options_set_min_tls_protocol_version(tls.securityProtocolOptions, .TLSv12)
let parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
listener = try NWListener(using: parameters)
}
func start() async throws {
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
switch state {
case .ready: self?.started = true
case .failed: self?.failure = "TLS listener failed"
default: break
}
}
}
listener.newConnectionHandler = { [weak self] socket in
Task { @MainActor in
guard let self else { socket.cancel(); return }
self.socket?.cancel()
self.socket = socket
self.stream = CastWire.Stream()
socket.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state { self?.receive(socket) }
}
}
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
}
}
listener.start(queue: DispatchQueue.global(qos: .userInitiated))
try await until("TLS listener starts") { started || failure != nil }
require(failure == nil, "TLS listener succeeds")
}
func stop() { socket?.cancel(); listener.cancel() }
private func receive(_ socket: NWConnection) {
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
Task { @MainActor in
guard let self else { return }
do {
if let data {
for message in try self.stream.append(data) { try await self.handle(message) }
}
if !ended, error == nil { self.receive(socket) }
} catch { self.failure = "Simulated receiver protocol/relay failure: \(error.localizedDescription)" }
}
}
}
private func send(_ json: [String: Any], namespace: String, source: String? = nil, fragmented: Bool = false) throws {
let payload = try JSONSerialization.data(withJSONObject: json)
let frame = try CastWire.frame(.init(source: source ?? (namespace == CastWire.media ? transport : "receiver-0"),
destination: sender, namespace: namespace, payload: payload))
if fragmented {
// Split both the length prefix and protobuf, exercising genuine receive framing.
for chunk in [frame.prefix(2), frame.dropFirst(2).prefix(5), frame.dropFirst(7)] {
socket?.send(content: Data(chunk), completion: .contentProcessed { _ in })
}
} else { socket?.send(content: frame, completion: .contentProcessed { _ in }) }
}
private func receiverStatus(_ requestID: Int) throws {
try send(["type": "RECEIVER_STATUS", "requestId": requestID,
"status": ["applications": [["appId": "CC1AD845", "sessionId": "app-session", "transportId": transport]],
"volume": ["level": volume, "muted": muted]]], namespace: CastWire.receiver, fragmented: true)
}
func status(requestID: Int = 0, session override: Int? = nil, content: String? = nil,
state: String? = nil, idle: String? = nil) throws {
var media = self.media
if let content { media["contentId"] = content }
try send(["type": "MEDIA_STATUS", "requestId": requestID,
"status": [["mediaSessionId": override ?? session, "media": media,
"playerState": state ?? (playing ? "PLAYING" : "PAUSED"), "currentTime": position,
"idleReason": idle ?? "", "supportedMediaCommands": 4099, "activeTrackIds": activeTracks]]],
namespace: CastWire.media, fragmented: true)
}
func releaseSeek() throws {
guard let command = heldSeek else { return }
heldSeek = nil
position = command["currentTime"] as? Double ?? position
try status(requestID: command["requestId"] as? Int ?? 0)
}
func invalidFrame() {
socket?.send(content: Data([0, 1, 0, 1]), completion: .contentProcessed { _ in }) // 65537 > bound
}
func takeOverApplication() throws {
try send(["type": "RECEIVER_STATUS", "status": ["applications": []]], namespace: CastWire.receiver)
}
private func handle(_ message: CastWire.Message) async throws {
sender = message.source
let json = try JSONSerialization.jsonObject(with: message.payload) as! [String: Any]
let type = json["type"] as! String
let requestID = json["requestId"] as? Int ?? 0
commands.append((type, json))
if message.namespace == CastWire.heartbeat {
if type == "PING", !ignoreHeartbeat { try send(["type": "PONG"], namespace: CastWire.heartbeat) }
return
}
switch type {
case "CONNECT": break
case "CLOSE": break
case "LAUNCH":
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
if dropLaunch { return }
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
else { try receiverStatus(requestID) }
case "LOAD":
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
if dropLoad { return }
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: requestID)
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
if dropCommands { return }
if rejectCommand {
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
return
}
require(json["mediaSessionId"] as? Int == session, "Controls address the acquired media session")
if type == "PLAY" { playing = true }
if type == "PAUSE" { playing = false }
if type == "EDIT_TRACKS_INFO" { activeTracks = json["activeTrackIds"] as? [Int] ?? [] }
if type == "SEEK" {
if holdSeek { heldSeek = json; try status(); return }
position = json["currentTime"] as? Double ?? position
}
try status(requestID: requestID)
case "SET_VOLUME":
require(message.namespace == CastWire.receiver, "Volume controls device, not stream")
volume = (json["volume"] as? [String: Any])?["level"] as? Double ?? volume
muted = (json["volume"] as? [String: Any])?["muted"] as? Bool ?? muted
try receiverStatus(requestID)
case "STOP":
require(message.namespace == CastWire.media, "Disconnect does not stop another sender's application")
require(json["mediaSessionId"] as? Int == session, "Disconnect stops only owned media")
default: failure = "Unexpected command \(type)"
}
}
private func fetchRelay() async throws {
let url = URL(string: media["contentId"] as! String)!
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 30
let session = URLSession(configuration: config)
defer { session.invalidateAndCancel() }
func fetch(_ url: URL) async throws -> Data {
let (data, response) = try await session.data(from: url)
require((response as? HTTPURLResponse)?.statusCode == 200, "Receiver fetch succeeds")
require(!data.isEmpty, "Relay returns real bytes")
return data
}
var playlistURL = url
var playlist = try await fetch(playlistURL)
var body = String(data: playlist, encoding: .utf8) ?? ""
require(body.hasPrefix("#EXTM3U"), "LOAD is playable HLS")
for _ in 0..<3 {
guard let uri = body.split(separator: "\n").first(where: { !$0.hasPrefix("#") && !$0.isEmpty }),
let next = URL(string: String(uri).trimmingCharacters(in: .whitespacesAndNewlines), relativeTo: playlistURL)?.absoluteURL else {
fatalError("FAIL: playlist has a media resource")
}
let data = try await fetch(next)
if let text = String(data: data, encoding: .utf8), text.hasPrefix("#EXTM3U") {
playlistURL = next; playlist = data; body = text; continue
}
require(data.count >= 188 && data.first == 0x47 || data.count >= 12 &&
["ftyp", "styp", "moof"].contains(String(data: data[4..<8], encoding: .ascii) ?? ""),
"Relay serves MPEG-TS or fragmented MP4, not placeholder content")
for track in media["tracks"] as? [[String: Any]] ?? [] {
if let textURL = (track["trackContentId"] as? String).flatMap(URL.init(string:)) {
let text = try await fetch(textURL)
require(String(data: text, encoding: .utf8)?.hasPrefix("WEBVTT") == true, "Receiver subtitles are WebVTT")
}
}
relayVerified = true
return
}
fatalError("FAIL: receiver playlist resolves to real media")
}
}
@MainActor
private func testIdentity() throws -> (SecIdentity, URL) {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-check-" + UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
func openssl(_ arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/openssl")
process.arguments = arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
require(process.terminationStatus == 0, "Creates ephemeral self-signed TLS test identity")
}
let key = directory.appendingPathComponent("key.pem").path
let certificate = directory.appendingPathComponent("cert.pem").path
let bundle = directory.appendingPathComponent("identity.p12")
try openssl(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key,
"-out", certificate, "-days", "1", "-subj", "/CN=Cast loopback"])
try openssl(["pkcs12", "-export", "-inkey", key, "-in", certificate, "-out", bundle.path,
"-passout", "pass:cast-check", "-keypbe", "PBE-SHA1-3DES", "-certpbe", "PBE-SHA1-3DES", "-macalg", "sha1"])
let data = try Data(contentsOf: bundle)
var result: CFArray?
let status = SecPKCS12Import(data as CFData,
[kSecImportExportPassphrase as String: "cast-check"] as CFDictionary, &result)
require(status == errSecSuccess, "Imports ephemeral TLS identity")
let item = (result! as! [[String: Any]])[0]
return (item[kSecImportItemIdentity as String] as! SecIdentity, directory)
}
@main
@MainActor
struct CastSessionCheck {
static func codec() throws {
let payload = Data("{\"type\":\"PING\"}".utf8)
let message = CastWire.Message(source: "sender-0", destination: "receiver-0",
namespace: CastWire.heartbeat, payload: payload)
let frame = try CastWire.frame(message)
var stream = CastWire.Stream()
var decoded: [CastWire.Message] = []
for byte in frame { decoded += try stream.append(Data([byte])) }
require(decoded.count == 1 && decoded[0].payload == payload, "Byte-fragmented frame round trips")
let pair = try stream.append(frame + frame)
require(pair.count == 2, "Coalesced frames decode")
for malformed in [Data([0, 0, 0, 0]), Data([0, 1, 0, 1]), Data([255, 255, 255, 255])] {
do { var parser = CastWire.Stream(); _ = try parser.append(malformed); fatalError("FAIL: invalid length accepted") }
catch CastError.protocolError { }
}
for malformed in [Data([8, 128]), Data([8, 0, 18, 127]), Data([15]), Data(repeating: 255, count: 12)] {
do { _ = try CastWire.decode(malformed); fatalError("FAIL: malformed protobuf accepted") }
catch CastError.protocolError { }
}
do {
_ = try CastWire.frame(.init(source: "s", destination: "r", namespace: "m",
payload: Data(repeating: 65, count: CastWire.maximumFrame)))
fatalError("FAIL: oversized outbound frame accepted")
} catch CastError.protocolError { }
}
static func media(_ suffix: String = "first") -> CastMediaDescription {
.init(url: URL(string: "http://127.0.0.1:9123/session-token/\(suffix).m3u8")!,
contentType: "application/x-mpegURL", duration: 120, live: false,
tracks: [.init(id: 17, url: URL(string: "http://127.0.0.1:9123/session-token/subtitle.vtt")!,
language: "en", label: "English")])
}
static func behavior(identity: SecIdentity) async throws {
let peer = try Receiver(identity: identity)
peer.muted = true
try await peer.start()
defer { peer.stop() }
let clock = CastCheckClock()
let session = CastSession(sleep: { try await clock.sleep($0) })
var statuses: [CastStatus] = []
var errors: [String] = []
var disconnected = false
session.onStatus = { statuses.append($0) }
session.onError = { errors.append($0) }
session.onDisconnect = { _ in disconnected = true }
try await session.connect(to: peer.device)
require(peer.commands.first?.0 == "CONNECT", "TLS platform channel connects before launching")
require(!session.setPlaying(true), "No media control before load acknowledgement")
try await session.load(media(), title: "Check", position: 9.25, playing: false, subtitle: 0)
require(statuses.last?.position == 9.25 && statuses.last?.playing == false && statuses.last?.subtitle == 0,
"LOAD is receiver-authoritative and subtitle IDs map to indices")
require(statuses.last?.volume == 0, "Pre-existing device mute is reflected in controls")
require(session.setPlaying(true) && session.setPlaying(false), "Rapid opposite commands accepted")
try await until("PLAY and PAUSE acknowledged in order") {
peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.count == 2 && statuses.last?.playing == false
}
require(peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.map(\.0) == ["PLAY", "PAUSE"], "Ordered controls")
let beforeBurst = statuses.count
require(session.setPlaying(true) && session.setPlaying(false) && session.setPlaying(true),
"Three rapid intents are accepted")
try await until("Latest playback intent is acknowledged") {
statuses.last?.playing == true && statuses.last?.playbackPending == false
}
require(statuses.dropFirst(beforeBurst).contains { $0.playing && $0.playbackPending },
"An older matching PLAY cannot settle the latest playback intent")
require(session.setPlaying(false), "Pause before seek checks")
try await until("Pause settles") { statuses.last?.playing == false && statuses.last?.playbackPending == false }
peer.holdSeek = true
let beforeSeek = statuses.count
require(session.seek(24.25), "SEEK accepted")
try await until("Old status arrives while SEEK pending") {
peer.commands.contains { $0.0 == "SEEK" } && statuses.count >= beforeSeek + 2
}
require(statuses.last?.seekPending == true && statuses.last?.position == 9.25,
"Old status cannot clear an unacknowledged seek or invent position")
try await until("Heartbeat is waiting on the controlled clock") { clock.hasDelay(.seconds(5)) }
clock.advance(.seconds(5))
try await until("Newer status poll replies before held SEEK") {
peer.commands.contains { $0.0 == "GET_STATUS" } && statuses.last?.seekPending == true
}
try peer.releaseSeek()
try await until("SEEK request correlation") { statuses.last?.seekPending == false && statuses.last?.position == 24.25 }
require(session.setVolume(0.3), "Device volume accepted")
try await until("Device volume acknowledged") { statuses.last?.volume == 0.3 }
require(session.setSubtitle(nil), "Subtitle off accepted")
try await until("Subtitle off acknowledged") { statuses.last?.subtitle == nil }
let oldSession = 2
try await session.load(media("replacement"), title: "Replacement", position: 3, playing: true, subtitle: nil)
let statusCount = statuses.count
try peer.status(session: oldSession, content: media().url.absoluteString)
try peer.status()
try await until("Valid status after stale frame provides an ordered receiver barrier") {
statuses.count >= statusCount + 1
}
require(statuses.count == statusCount + 1 && !disconnected, "Only the valid barrier status is published")
try peer.status(state: "IDLE", idle: "FINISHED")
try await until("Receiver reports natural end") { statuses.last?.ended == true }
let beforeReplay = peer.commands.filter { $0.0 == "LOAD" }.count
require(session.setPlaying(true) && session.setPlaying(false), "Ended replay and rapid pause accepted")
try await until("Ended replay uses LOAD from zero then honors pause") {
peer.commands.filter { $0.0 == "LOAD" }.count == beforeReplay + 1 &&
statuses.last?.position == 0 && statuses.last?.playing == false && statuses.last?.ended == false
}
require(peer.commands.last(where: { $0.0 == "LOAD" })?.1["currentTime"] as? Double == 0,
"Default receiver ended media reloaded, not invalid PLAY")
require(!session.seek(.nan) && !session.setVolume(2) && !session.setSubtitle(99), "Invalid command input rejected")
session.disconnect()
try await until("Owned media STOP arrives") { peer.commands.contains { $0.0 == "STOP" } }
require(disconnected && errors.isEmpty && peer.failure == nil, "Safe disconnect without spurious cancellation errors")
}
static func connectionDeadline() async throws {
let listener = try NWListener(using: .tcp, on: .any)
@MainActor final class ListenerState {
var accepted: [NWConnection] = []
var listening = false
}
let state = ListenerState()
listener.stateUpdateHandler = { event in
Task { @MainActor in if case .ready = event { state.listening = true } }
}
listener.newConnectionHandler = { socket in
Task { @MainActor in
state.accepted.append(socket)
// Accept TCP but never answer the client's TLS handshake.
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
}
}
listener.start(queue: DispatchQueue.global(qos: .userInitiated))
defer { for socket in state.accepted { socket.cancel() }; listener.cancel() }
try await until("Incomplete TLS listener starts") { state.listening }
let clock = CastCheckClock()
let session = CastSession(sleep: { try await clock.sleep($0) })
let task = Task {
try await session.connect(to: .init(id: "stall", name: "Stalled TLS", host: "127.0.0.1",
port: listener.port!.rawValue))
}
try await until("Connection handshake waits on frozen clock") {
!state.accepted.isEmpty && clock.hasDelay(.seconds(12))
}
clock.advance(.seconds(12))
do { try await task.value; fatalError("FAIL: stalled TLS handshake succeeded") }
catch CastError.timeout { }
try await until("Connection deadline cancels all timer handles") { clock.pendingCount == 0 }
}
static func failures(identity: SecIdentity) async throws {
for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame",
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue"] {
let peer = try Receiver(identity: identity)
try await peer.start()
let clock = CastCheckClock()
let session = CastSession(requestTimeout: .milliseconds(400), loadTimeout: .milliseconds(400),
heartbeatInterval: mode == "heartbeat" ? .milliseconds(100) : .seconds(5),
sleep: { try await clock.sleep($0) })
var disconnected = false
var errors: [String] = []
session.onDisconnect = { _ in disconnected = true }
session.onError = { errors.append($0) }
if mode == "launch" { peer.rejectLaunch = true }
if mode == "launch-timeout" { peer.dropLaunch = true }
if mode == "heartbeat" { peer.ignoreHeartbeat = true }
if mode == "launch" {
do { try await session.connect(to: peer.device); fatalError("FAIL: rejected launch succeeded") }
catch CastError.rejected { }
} else if mode == "launch-timeout" {
let task = Task { try await session.connect(to: peer.device) }
try await until("Receiver launch waits on frozen clock") {
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.milliseconds(400))
}
clock.advance(.milliseconds(400))
do { try await task.value; fatalError("FAIL: unanswered launch succeeded") }
catch CastError.timeout { require(disconnected, "Launch deadline releases channel") }
} else {
try await session.connect(to: peer.device)
try await until("Handshake deadline handles cancelled") { clock.pendingCount == 1 }
peer.rejectLoad = mode == "load"
peer.dropLoad = mode == "deadline" || mode == "cancel"
if mode == "load" || mode == "deadline" {
let task = Task { try await session.load(media(), title: "Failure", position: 0, playing: true, subtitle: nil) }
if mode == "deadline" {
try await until("LOAD waits on frozen clock") {
peer.commands.contains { $0.0 == "LOAD" } && clock.hasDelay(.milliseconds(400))
}
clock.advance(.milliseconds(400))
}
do { try await task.value; fatalError("FAIL: failed load succeeded") }
catch CastError.rejected { require(mode == "load", "LOAD rejection correlated") }
catch CastError.timeout { require(mode == "deadline", "LOAD deadline bounded") }
} else if mode == "cancel" {
let task = Task { try await session.load(media(), title: "Cancel", position: 0, playing: true, subtitle: nil) }
try await until("LOAD sent before cancellation") { peer.commands.contains { $0.0 == "LOAD" } }
task.cancel()
do { try await task.value; fatalError("FAIL: cancelled LOAD succeeded") }
catch { require(disconnected, "LOAD cancellation releases channel") }
} else {
try await session.load(media(), title: "Check", position: 0, playing: false, subtitle: nil)
switch mode {
case "command":
peer.dropCommands = true
require(session.seek(12), "Unanswered SEEK sent")
try await until("SEEK waits on frozen clock") {
peer.commands.contains { $0.0 == "SEEK" } && clock.hasDelay(.milliseconds(400))
}
clock.advance(.milliseconds(400))
try await until("Command deadline reports recoverable error") { !errors.isEmpty }
require(!disconnected, "Command timeout is not channel failure")
case "rejection":
peer.rejectCommand = true
require(session.setPlaying(true), "Rejected command sent")
try await until("Command rejection reports recoverable error") { !errors.isEmpty }
require(!disconnected, "Command rejection is not channel failure")
case "media-error": try peer.status(state: "IDLE", idle: "ERROR")
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED")
case "frame": peer.invalidFrame()
case "ownership": try peer.takeOverApplication()
case "heartbeat":
try await until("Heartbeat tick is scheduled on frozen clock") { clock.hasDelay(.milliseconds(100)) }
clock.advance(.milliseconds(100))
try await until("PING sent and next heartbeat tick scheduled") {
peer.commands.contains { $0.0 == "PING" } && clock.hasDelay(.milliseconds(100))
}
clock.advance(.milliseconds(100))
case "queue":
peer.dropCommands = true
var accepted = 0
for _ in 0..<40 { if session.setPlaying(true) { accepted += 1 } }
require(accepted == 32 && !errors.isEmpty, "Pending command resource bound enforced")
default: break
}
if ["frame", "ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat"].contains(mode) {
try await until("\(mode) causes bounded channel failure") { disconnected }
}
}
}
session.disconnect(stopReceiver: false)
try await until("Cancelled timer handles released") { clock.pendingCount == 0 }
peer.stop()
}
}
static func playback(identity: SecIdentity, executable: String, file: String) async throws {
let peer = try Receiver(identity: identity)
peer.verifyRelay = true
try await peer.start()
defer { peer.stop() }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = [file, String(peer.port)]
try process.run()
defer { if process.isRunning { process.terminate() } }
try await until("Integrated playback subprocess completes", attempts: 18000) { !process.isRunning || peer.failure != nil }
require(peer.failure == nil, peer.failure ?? "Simulated receiver accepts integration flow")
require(process.terminationStatus == 0, "Player model integration check succeeds")
try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } }
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(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 &&
controls[2].1["currentTime"] as? Double == 24.25, "Rapid skips accumulate from the pending target")
}
static func realPeer(_ arguments: [String]) async throws {
guard let port = UInt16(arguments[3]), let url = URL(string: arguments[4]) else {
fatalError("Usage: cast-session-check --peer HOST PORT MEDIA_URL CONTENT_TYPE")
}
let session = CastSession()
var status: CastStatus?
var error: String?
session.onStatus = { status = $0 }
session.onDisconnect = { error = $0 }
session.onError = { error = $0 }
defer { session.disconnect() }
try await session.connect(to: .init(id: "smoke", name: "Smoke receiver", host: arguments[2], port: port))
try await session.load(.init(url: url, contentType: arguments[5], duration: 0, live: false, tracks: []),
title: "Terebi Cast smoke", position: 0, playing: false, subtitle: nil)
require(status != nil, "Real receiver acknowledges LOAD")
require(session.setPlaying(true), "Real receiver PLAY accepted")
try await until("Real receiver plays", attempts: 1500) { status?.playing == true || error != nil }
require(error == nil && status?.playing == true, "Real receiver acknowledges PLAY")
require(session.setPlaying(false), "Real receiver PAUSE accepted")
try await until("Real receiver pauses", attempts: 1500) { status?.playing == false || error != nil }
require(error == nil && status?.playing == false, "Real receiver acknowledges PAUSE")
print("PASS: real peer TLS Default Media Receiver LOAD/PLAY/PAUSE (URLs intentionally omitted)")
}
static func main() async throws {
if CommandLine.arguments.count == 6, CommandLine.arguments[1] == "--peer" {
try await realPeer(CommandLine.arguments)
return
}
try codec()
let (identity, directory) = try testIdentity()
defer { try? FileManager.default.removeItem(at: directory) }
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
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")
} else {
try await behavior(identity: identity)
try await failures(identity: identity)
try await connectionDeadline()
print("PASS: bounded Cast framing, self-signed TLS launch/load/control, correlation, stale ownership, queue, deadlines, heartbeat, cancellation")
}
}
}
+12
View File
@@ -813,6 +813,18 @@ static void reporting(const std::string &base, const std::string &fixtures) {
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
[rows[0][@"completion_eligible"] boolValue],
"initial report is restored native position, not pre-resume zero");
PlayerState receiver = player_get_state(player.get());
receiver.position = 114;
receiver.status = PLAYER_PAUSED;
require(player_report_remote_state(player.get(), &receiver), "receiver-confirmed progress accepted");
require(player_close(player.get()), "close immediately after receiver progress");
until(player.get(), [&](auto state) {
auto current = progressRecords(path);
return state.status == PLAYER_EMPTY && [current.lastObject[@"event"] isEqualToString:@"closed"] &&
[current.lastObject[@"position_seconds"] doubleValue] == 114;
}, "closing drains final Cast position instead of abandoned decoder position");
require([progressRecords(path).lastObject[@"completion_eligible"] boolValue],
"receiver-confirmed video remains completion eligible");
require(openNetwork(player.get(), base, "/failure/av.mp4", "", 4, false, true, second),
"failed requested identity accepted asynchronously");
until(player.get(), [](auto state) { return state.status == PLAYER_ERROR; }, "failed reported open", true);
+25 -5
View File
@@ -12,8 +12,8 @@ DEBUG_FLAGS ?= -g
PREFIX ?= $(HOME)/.local
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.o
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
CONFIG_BUILD := .build/configuration
@@ -27,7 +27,7 @@ CLI := $(APP)/Contents/MacOS/terebi-cli
BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
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
.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
all: $(BUILD_DIR)/terebi
$(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json
@@ -62,12 +62,15 @@ $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
mkdir -p "$(APP)/Contents/Resources"
cp -f "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@"
$(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(BUILD_DIR)/player.o: 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 Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/cast_media.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/network_policy.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
@@ -102,7 +105,7 @@ run: all
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-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: 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)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies
@@ -224,6 +227,23 @@ $(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES
check-media: $(BUILD_DIR)/system-playback-check check-fixtures
$(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures
$(BUILD_DIR)/cast-session-check: Checks/cast-session.swift Sources/App/CastSession.swift Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DCAST_SESSION_CHECKS -O $(DEBUG_FLAGS) Checks/cast-session.swift Sources/App/CastSession.swift -framework Foundation -framework Network -framework Security -o $@
$(BUILD_DIR)/cast_media-check.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/network_policy.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -DCAST_MEDIA_CHECK -c $< -o $@
$(BUILD_DIR)/cast-media-check: Checks/cast-media.cpp $(BUILD_DIR)/cast_media-check.o Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -ISources/Core -DCAST_MEDIA_CHECK $< $(BUILD_DIR)/cast_media-check.o $(FF_LIBS) -o $@
$(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
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-media-check --live
$(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
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
+25 -3
View File
@@ -1,6 +1,6 @@
# 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 playback. SwiftUI owns the controls, Core Audio owns audio output, and Metal presents video.
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.
## Build and run
@@ -17,7 +17,7 @@ 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.
`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, encoders, muxers, filters or unrelated autodetected packages; 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.
`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.
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.
@@ -198,13 +198,29 @@ Miruro's progress identity is independent of its source address. YouTube records
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. Live playback is never part of the default suite. 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.
`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 in the titlebar (or 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. No Chrome, hosted custom receiver, Google developer registration, or external casting runtime is required.
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.
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.
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. A production-paced live HLS smoke also decoded A/V across a segment boundary and shut down. **Physical Cast playback, receiver captions and perceived A/V synchronization remain human verification; the TODO stays open until that check.**
## 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.
@@ -292,6 +308,10 @@ Playback terms are defined in [CONTEXT.md](CONTEXT.md).
- `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.
@@ -339,6 +359,8 @@ The gated YouTube check also exercises compact shortcut/control entry and restor
`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.
+2
View File
@@ -20,5 +20,7 @@
</array>
<key>NSHighResolutionCapable</key><true/>
<key>NSPrincipalClass</key><string>NSApplication</string>
<key>NSLocalNetworkUsageDescription</key><string>Discover Cast devices and stream your selected media to a TV on your local network.</string>
<key>NSBonjourServices</key><array><string>_googlecast._tcp</string></array>
</dict>
</plist>
+1
View File
@@ -144,6 +144,7 @@ def native(root, arch, lock):
"--extra-ldflags=" + flags + " " + shlex.quote("-L" + str(root / "xz/lib")),
"--enable-shared", "--disable-static", "--disable-programs", "--disable-doc",
"--disable-encoders", "--disable-muxers", "--disable-avdevice", "--disable-avfilter",
"--enable-encoder=aac,h264_videotoolbox", "--enable-muxer=mpegts",
"--disable-gpl", "--disable-nonfree", "--disable-version3", "--disable-autodetect",
"--enable-libdav1d", "--enable-securetransport", "--enable-videotoolbox",
"--enable-audiotoolbox", "--enable-zlib", "--enable-bzlib", "--enable-lzma"]
+48
View File
@@ -0,0 +1,48 @@
import SwiftUI
@MainActor
struct CastDeviceMenu: View {
@ObservedObject var model: PlayerModel
@ObservedObject private var discovery: CastDiscovery
var icon = true
init(model: PlayerModel, icon: Bool = true) {
self.model = model
self.discovery = model.castDiscovery
self.icon = icon
}
var body: some View {
Menu {
if let device = model.castDevice {
Text((model.castConnecting ? "Connecting to " : "Casting to ") + device.name)
Button(model.castConnecting ? "Cancel Casting" : "Play on This Mac") { model.stopCasting() }
Divider()
}
ForEach(discovery.devices) { device in
Button(device.name) { model.cast(to: device) }
.disabled(!model.canCast)
}
if discovery.devices.isEmpty {
Text("No Cast devices discovered")
}
if let error = discovery.error { Text(error) }
Divider()
Button("Find Cast Devices…") { discovery.start() }
} label: {
if icon {
Image(systemName: "tv.badge.wifi")
.font(.system(size: 14))
.frame(width: 24, height: 24)
} else {
Text("Cast to Device")
}
}
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel("Cast to Device")
.help(model.castDevice.map { "Casting to " + $0.name } ?? "Cast to Device")
}
}
+145
View File
@@ -0,0 +1,145 @@
import Foundation
struct CastMediaInput: Sendable {
let videoURL: String
let videoHeaders: String
let audioURL: String?
let audioHeaders: String?
let local: Bool
let restricted: Bool
let live: Bool
let duration: Double
let subtitles: [ResolvedSubtitle]
}
struct CastMediaDescription: Sendable {
let url: URL
let contentType: String
let duration: Double
let live: Bool
let tracks: [CastTextTrack]
}
struct CastTextTrack: Sendable {
let id: Int
let url: URL
let language: String
let label: String
}
private struct CastDeliveryError: LocalizedError {
let errorDescription: String? = "Could not deliver cast media. Check the connection and media compatibility. Private details are redacted."
}
// A start task retains this owner until native probing returns. Cancellation never
// frees a handle in use; destruction and worker joins always run off the main actor.
private final class CastMediaLifetime: @unchecked Sendable {
let handle: OpaquePointer
init(input: CastMediaInput) throws {
var error = [CChar](repeating: 0, count: 512)
let result = input.videoURL.withCString { video in
input.videoHeaders.withCString { headers in
(input.audioURL ?? "").withCString { audio in
(input.audioHeaders ?? "").withCString { audioHeaders in
var source = CastMediaSource(video_url: video, video_headers: headers,
audio_url: audio, audio_headers: audioHeaders, local: input.local,
restricted: input.restricted, live: input.live, duration: input.duration)
return cast_media_create(&source, &error)
}
}
}
}
guard let result else { throw CastDeliveryError() }
handle = result
}
func cancel() { cast_media_cancel(handle) }
func start(host: String) throws -> (String, Double) {
var url = [CChar](repeating: 0, count: 512)
var error = [CChar](repeating: 0, count: 512)
var duration = 0.0
guard host.withCString({ cast_media_start(handle, $0, &url, &duration, &error) }) else {
throw CastDeliveryError()
}
return (url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }, duration)
}
func subtitle(id: Int, data: Data) throws -> URL {
var url = [CChar](repeating: 0, count: 512)
let success = data.withUnsafeBytes {
cast_media_subtitle(handle, Int32(id), $0.baseAddress, $0.count, &url)
}
guard success, let result = URL(string: url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }) else { throw CastDeliveryError() }
return result
}
deinit {
// Capture an explicitly Sendable owner instead of crossing an actor with a pointer.
let cleanup = CastMediaCleanup(handle: handle)
DispatchQueue.global(qos: .utility).async { cast_media_destroy(cleanup.handle) }
}
}
private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer }
@MainActor final class CastMediaServer {
private var lifetime: CastMediaLifetime?
private var operation: Task<CastMediaDescription, Error>?
init() {}
func start(input: CastMediaInput, receiverHost: String) async throws -> CastMediaDescription {
stop()
guard input.subtitles.count <= 32 else { throw CastDeliveryError() }
let owner = try CastMediaLifetime(input: input)
lifetime = owner
let task = Task.detached(priority: .userInitiated) {
do {
let (text, duration) = try owner.start(host: receiverHost)
try Task.checkCancellation()
guard let url = URL(string: text) else { throw CastDeliveryError() }
var tracks: [CastTextTrack] = []
for (index, subtitle) in input.subtitles.enumerated() {
try Task.checkCancellation()
let data = try await WebVTTDocument.loadData(url: subtitle.url, headers: subtitle.headers)
guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() }
_ = try WebVTTDocument.parse(text)
let trackURL = try owner.subtitle(id: index + 1, data: data)
tracks.append(CastTextTrack(id: index + 1, url: trackURL,
language: subtitle.language, label: subtitle.label))
}
try Task.checkCancellation()
return CastMediaDescription(url: url, contentType: "application/vnd.apple.mpegurl",
duration: duration, live: input.live, tracks: tracks)
} catch {
owner.cancel()
if Task.isCancelled { throw CancellationError() }
throw CastDeliveryError()
}
}
operation = task
do {
let result = try await withTaskCancellationHandler {
try await task.value
} onCancel: {
owner.cancel()
task.cancel()
}
guard lifetime === owner else { throw CancellationError() }
operation = nil
return result
} catch {
if lifetime === owner { stop() }
if error is CancellationError { throw error }
throw CastDeliveryError()
}
}
func stop() {
operation?.cancel()
operation = nil
lifetime?.cancel()
lifetime = nil
}
deinit {
operation?.cancel()
lifetime?.cancel()
}
}
+806
View File
@@ -0,0 +1,806 @@
import Foundation
import Combine
@preconcurrency import Network
import Security
struct CastDevice: Identifiable, Equatable, Sendable {
let id: String
let name: String
let host: String
let port: UInt16
}
@MainActor
final class CastDiscovery: NSObject, ObservableObject, @preconcurrency NetServiceBrowserDelegate,
@preconcurrency NetServiceDelegate {
@Published private(set) var devices: [CastDevice] = []
@Published private(set) var error: String?
private var browser: NetServiceBrowser?
private var services: [String: NetService] = [:]
private var resolved: [String: CastDevice] = [:]
func start() {
if error != nil { stop() }
guard browser == nil else { return }
error = nil
let browser = NetServiceBrowser()
self.browser = browser
browser.delegate = self
browser.searchForServices(ofType: "_googlecast._tcp.", inDomain: "local.")
}
func stop() {
browser?.stop()
browser?.delegate = nil
browser = nil
for service in services.values { service.stop(); service.delegate = nil }
services.removeAll()
resolved.removeAll()
devices.removeAll()
}
private func key(_ service: NetService) -> String { service.domain + service.type + service.name }
private func publish() {
// Multiple advertised interfaces may resolve to the same receiver ID.
var unique: [String: CastDevice] = [:]
for device in resolved.values { unique[device.id] = device }
devices = unique.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
guard browser === self.browser, services.count < 128 else { return }
let key = self.key(service)
guard services[key] == nil else { return }
services[key] = service
service.delegate = self
service.resolve(withTimeout: 5)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
guard browser === self.browser else { return }
let key = self.key(service)
services.removeValue(forKey: key)?.stop()
resolved.removeValue(forKey: key)
publish()
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
guard browser === self.browser else { return }
error = "Cast discovery failed. Check Local Network permission and your Wi-Fi connection."
}
func netServiceDidResolveAddress(_ sender: NetService) {
let key = self.key(sender)
guard services[key] === sender, let host = sender.hostName,
sender.port > 0, sender.port <= Int(UInt16.max) else { return }
let txt = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
func text(_ key: String) -> String? {
txt[key].flatMap { String(data: $0, encoding: .utf8) }.flatMap { $0.isEmpty ? nil : $0 }
}
resolved[key] = CastDevice(id: text("id") ?? key, name: text("fn") ?? sender.name,
host: host, port: UInt16(sender.port))
publish()
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
let key = self.key(sender)
guard services[key] === sender else { return }
services.removeValue(forKey: key)?.stop()
}
}
struct CastStatus: Sendable {
let position: Double
let duration: Double
let playing: Bool
let buffering: Bool
let ended: Bool
let volume: Double
let seekable: Bool
let subtitle: Int?
let seekPending: Bool
let playbackPending: Bool
}
enum CastError: Error, LocalizedError, Sendable {
case protocolError, disconnected, cancelled, timeout(String), rejected(String), invalidMedia, queueFull
var errorDescription: String? {
switch self {
case .protocolError: "The Cast receiver sent an invalid message."
case .disconnected: "The Cast receiver disconnected."
case .cancelled: "The Cast operation was cancelled."
case .timeout(let operation): "The Cast receiver did not acknowledge \(operation) in time."
case .rejected(let operation): "The Cast receiver rejected \(operation)."
case .invalidMedia: "The media or subtitle selection cannot be cast."
case .queueFull: "Too many Cast commands are waiting for a response."
}
}
}
// Cast v2's original (unchunked) envelope, from Open Screen's cast_channel.proto:
// https://chromium.googlesource.com/openscreen/+/refs/heads/main/cast/common/channel/proto/cast_channel.proto
// Media transport messages are limited to 64 KiB by https://developers.google.com/cast/docs/media/messages.
enum CastWire {
static let maximumFrame = 64 * 1024
static let connection = "urn:x-cast:com.google.cast.tp.connection"
static let heartbeat = "urn:x-cast:com.google.cast.tp.heartbeat"
static let receiver = "urn:x-cast:com.google.cast.receiver"
static let media = "urn:x-cast:com.google.cast.media"
struct Message: Sendable {
let source: String
let destination: String
let namespace: String
let payload: Data
}
static func varint(_ value: UInt64, into data: inout Data) {
var value = value
repeat {
let byte = UInt8(value & 127)
value >>= 7
data.append(byte | (value == 0 ? 0 : 128))
} while value != 0
}
static func frame(_ message: Message) throws -> Data {
var body = Data([8, 0]) // protocol_version CASTV2_1_0
for (field, text) in [(2, message.source), (3, message.destination), (4, message.namespace)] {
let bytes = Data(text.utf8)
varint(UInt64(field << 3 | 2), into: &body)
varint(UInt64(bytes.count), into: &body)
body.append(bytes)
}
body.append(contentsOf: [40, 0, 50]) // payload_type STRING; payload_utf8
varint(UInt64(message.payload.count), into: &body)
body.append(message.payload)
guard body.count <= maximumFrame else { throw CastError.protocolError }
let count = UInt32(body.count)
var frame = Data([UInt8(count >> 24), UInt8((count >> 16) & 255),
UInt8((count >> 8) & 255), UInt8(count & 255)])
frame.append(body)
return frame
}
static func decode(_ data: Data) throws -> Message {
guard !data.isEmpty, data.count <= maximumFrame else { throw CastError.protocolError }
var offset = 0
func integer() throws -> UInt64 {
var value: UInt64 = 0
for shift in stride(from: 0, through: 63, by: 7) {
guard offset < data.count else { throw CastError.protocolError }
let byte = data[data.startIndex + offset]
offset += 1
guard shift != 63 || byte <= 1 else { throw CastError.protocolError }
value |= UInt64(byte & 127) << shift
if byte & 128 == 0 { return value }
}
throw CastError.protocolError
}
var numbers: [Int: UInt64] = [:]
var bytes: [Int: Data] = [:]
while offset < data.count {
let key = try integer()
guard key >> 3 > 0, key >> 3 <= UInt64(Int32.max) else { throw CastError.protocolError }
let field = Int(key >> 3)
switch key & 7 {
case 0:
let value = try integer()
if field == 1 || field == 5 || field == 8 { numbers[field] = value }
case 1, 5:
let size = key & 7 == 1 ? 8 : 4
guard size <= data.count - offset else { throw CastError.protocolError }
offset += size
case 2:
let size = try integer()
guard size <= UInt64(data.count - offset) else { throw CastError.protocolError }
let end = offset + Int(size)
if [2, 3, 4, 6].contains(field) {
bytes[field] = data.subdata(in: (data.startIndex + offset)..<(data.startIndex + end))
}
offset = end
default: throw CastError.protocolError
}
}
guard numbers[1] == 0, numbers[5] == 0, numbers[8, default: 0] == 0,
let sourceData = bytes[2], let source = String(data: sourceData, encoding: .utf8), !source.isEmpty,
let destinationData = bytes[3], let destination = String(data: destinationData, encoding: .utf8), !destination.isEmpty,
let namespaceData = bytes[4], let namespace = String(data: namespaceData, encoding: .utf8), !namespace.isEmpty,
let payload = bytes[6], String(data: payload, encoding: .utf8) != nil else {
throw CastError.protocolError
}
return Message(source: source, destination: destination, namespace: namespace, payload: payload)
}
struct Stream {
private var buffer = Data()
mutating func append(_ data: Data) throws -> [Message] {
// Network reads are <= 16 KiB. Bound incomplete input even before its length is known.
guard data.count <= maximumFrame, buffer.count + data.count <= maximumFrame * 2 + 4 else {
throw CastError.protocolError
}
buffer.append(data)
var messages: [Message] = []
while buffer.count >= 4 {
let i = buffer.startIndex
let length = buffer[i..<i + 4].reduce(0) { ($0 << 8) | Int($1) }
guard length > 0, length <= maximumFrame else { throw CastError.protocolError }
guard buffer.count >= length + 4 else { break }
messages.append(try decode(buffer.subdata(in: (i + 4)..<(i + 4 + length))))
buffer.removeFirst(length + 4)
}
return messages
}
}
}
@MainActor
final class CastSession {
var onStatus: ((CastStatus) -> Void)?
var onDisconnect: ((String?) -> Void)?
var onError: ((String) -> Void)?
var localHost: String? {
guard let endpoint = connection?.currentPath?.localEndpoint,
case .hostPort(let host, _) = endpoint else { return nil }
return String(describing: host)
}
private struct Pending {
let namespace: String
let destination: String
let operation: String
let generation: UUID?
let completion: ([String: Any]?, CastError?) -> Void
let deadline: Task<Void, Never>
}
private let makeConnection: (CastDevice) -> NWConnection
private let requestTimeout: Duration
private let loadTimeout: Duration
private let heartbeatInterval: Duration
private let sleep: @MainActor (Duration) async throws -> Void
private var connection: NWConnection?
private var connectionID = UUID()
private let source = "terebi-" + UUID().uuidString
private var stream = CastWire.Stream()
private var ready: CheckedContinuation<Void, any Error>?
private var readyDeadline: Task<Void, Never>?
private var heartbeatTask: Task<Void, Never>?
private var awaitingPong = false
private var nextRequest = 0
private var pending: [Int: Pending] = [:]
private var writes: [Data] = []
private var writing = false
private var appTransport: String?
private var appSession: String?
private var mediaSession: Int?
private var retiredSessions: Set<Int> = []
private var retiredLoads: [String] = []
private var contentID: String?
private var mediaGeneration: UUID?
private var tracks: [CastTextTrack] = []
private var latestStatus: CastStatus?
private var pendingSeek: Int?
private var pendingPlayback: Int?
var playbackPending: Bool { pendingPlayback != nil || replayTask != nil || loading }
private var lastStatusPoll = 0
private var newestMediaReply = 0
private var loading = false
private var loadedMedia: CastMediaDescription?
private var loadedTitle = ""
private var replayTask: Task<Void, Never>?
private var replayDesiredPlaying = true
private var receiverVolume = 1.0
init(requestTimeout: Duration = .seconds(8), loadTimeout: Duration = .seconds(25),
heartbeatInterval: Duration = .seconds(5),
sleep: @escaping @MainActor (Duration) async throws -> Void = { try await Task.sleep(for: $0) }) {
makeConnection = { device in
let tls = NWProtocolTLS.Options()
sec_protocol_options_set_min_tls_protocol_version(tls.securityProtocolOptions, .TLSv12)
// Cast receivers use self-signed TLS certificates, not public Web PKI. This is
// encrypted local-network transport to the user's selected Bonjour endpoint,
// NOT authenticated receiver identity. mDNS and this channel are LAN-trust;
// never claim standard CA/hostname validation or send upstream credentials.
// Cast device-auth (Google device roots) is intentionally not impersonated.
sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { @Sendable _, _, complete in
complete(true)
}, DispatchQueue.global(qos: .userInitiated))
let parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
return NWConnection(host: NWEndpoint.Host(device.host),
port: NWEndpoint.Port(rawValue: device.port)!, using: parameters)
}
self.requestTimeout = requestTimeout
self.loadTimeout = loadTimeout
self.heartbeatInterval = heartbeatInterval
self.sleep = sleep
}
func connect(to device: CastDevice) async throws {
disconnect(stopReceiver: false)
guard device.port > 0, !device.host.isEmpty else { throw CastError.disconnected }
let id = UUID()
connectionID = id
let socket = makeConnection(device)
connection = socket
stream = CastWire.Stream()
try await withTaskCancellationHandler {
try Task.checkCancellation()
try await withCheckedThrowingContinuation { continuation in
ready = continuation
readyDeadline = Task { [weak self] in
guard let self else { return }
do { try await self.sleep(.seconds(12)) } catch { return }
guard self.connectionID == id else { return }
self.fail(.timeout("connection"))
}
socket.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self, self.connectionID == id else { return }
switch state {
case .ready:
self.receive(socket, id: id)
self.beginHandshake()
case .failed, .cancelled: self.fail(.disconnected)
default: break
}
}
}
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
}
} onCancel: {
Task { @MainActor [weak self] in
guard let self, self.connectionID == id else { return }
self.fail(.cancelled)
}
}
}
private func beginHandshake() {
do {
try send(["type": "CONNECT", "origin": [:], "userAgent": "Terebi"],
namespace: CastWire.connection, destination: "receiver-0")
startHeartbeat()
try request(["type": "LAUNCH", "appId": "CC1AD845"], namespace: CastWire.receiver,
destination: "receiver-0", operation: "receiver launch") { [weak self] json, error in
guard let self else { return }
if let error { self.fail(error); return }
guard let status = json?["status"] as? [String: Any],
let apps = status["applications"] as? [[String: Any]],
let app = apps.first(where: { $0["appId"] as? String == "CC1AD845" }),
let transport = app["transportId"] as? String, !transport.isEmpty,
let session = app["sessionId"] as? String, !session.isEmpty else {
self.fail(.rejected("receiver launch")); return
}
self.appTransport = transport
self.appSession = session
do {
try self.send(["type": "CONNECT", "origin": [:]],
namespace: CastWire.connection, destination: transport)
} catch { self.fail(.protocolError); return }
self.readyDeadline?.cancel()
self.readyDeadline = nil
let ready = self.ready
self.ready = nil
ready?.resume()
}
} catch { fail(error as? CastError ?? .protocolError) }
}
func load(_ media: CastMediaDescription, title: String, position: Double,
playing: Bool, subtitle: Int?) async throws {
guard let transport = appTransport, connection != nil else { throw CastError.disconnected }
guard position.isFinite, position >= 0, media.duration.isFinite,
["http", "https"].contains(media.url.scheme?.lowercased() ?? ""),
media.url.absoluteString.utf8.count <= 1024,
subtitle == nil || media.tracks.indices.contains(subtitle!), media.tracks.count <= 128 else {
throw CastError.invalidMedia
}
cancelMediaRequests()
if let mediaSession {
if retiredSessions.count >= 16 { retiredSessions.removeAll() }
retiredSessions.insert(mediaSession)
}
if let mediaGeneration {
if retiredLoads.count == 64 { retiredLoads.removeFirst() }
retiredLoads.append(mediaGeneration.uuidString)
}
mediaSession = nil
latestStatus = nil
pendingSeek = nil
pendingPlayback = nil
newestMediaReply = 0
let generation = UUID()
mediaGeneration = generation
contentID = media.url.absoluteString
tracks = media.tracks
loadedMedia = media
loadedTitle = title
loading = true
var info: [String: Any] = [
"contentId": media.url.absoluteString, "contentType": media.contentType,
"streamType": media.live ? "LIVE" : "BUFFERED",
"metadata": ["metadataType": 0, "title": String(title.prefix(1024))],
"customData": ["terebiSession": generation.uuidString]
]
if media.duration > 0, !media.live { info["duration"] = media.duration }
if !media.tracks.isEmpty {
info["tracks"] = media.tracks.map { track -> [String: Any] in
["trackId": track.id, "type": "TEXT", "subtype": "SUBTITLES",
"trackContentId": track.url.absoluteString, "trackContentType": "text/vtt",
"name": track.label, "language": track.language]
}
}
var command: [String: Any] = ["type": "LOAD", "media": info, "autoplay": playing,
"activeTrackIds": subtitle.map { [media.tracks[$0].id] } ?? []]
if position > 0 || !media.live { command["currentTime"] = position }
let requestID = allocateRequest()
try await withTaskCancellationHandler {
try Task.checkCancellation()
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
do {
try request(command, namespace: CastWire.media, destination: transport,
operation: "media load", generation: generation, timeout: loadTimeout,
id: requestID) { [weak self] _, error in
if let self, self.mediaGeneration == generation { self.loading = false; self.publishSeekState() }
if let error { continuation.resume(throwing: error) }
else { continuation.resume() }
}
} catch { loading = false; continuation.resume(throwing: error) }
}
} onCancel: {
Task { @MainActor [weak self] in
guard let self, self.mediaGeneration == generation else { return }
// A cancelled LOAD may already be on the receiver. Tear down the channel
// rather than letting its late acknowledgement acquire ownership.
self.fail(.cancelled)
}
}
}
func setPlaying(_ playing: Bool) -> Bool {
if replayTask != nil { replayDesiredPlaying = playing; return true }
guard playing, latestStatus?.ended == true, let loadedMedia else {
return control(["type": playing ? "PLAY" : "PAUSE"])
}
let subtitle = latestStatus?.subtitle
let title = loadedTitle
replayDesiredPlaying = true
let connectionID = self.connectionID
replayTask = Task { [weak self] in
guard let self else { return }
do {
try await self.load(loadedMedia, title: title, position: 0, playing: true, subtitle: subtitle)
guard self.connectionID == connectionID else { return }
self.replayTask = nil
self.publishSeekState()
if !self.replayDesiredPlaying { _ = self.setPlaying(false) }
} catch {
guard self.connectionID == connectionID else { return }
self.replayTask = nil
self.publishSeekState()
if self.connection != nil { self.fail(error as? CastError ?? .disconnected) }
}
}
return true
}
func seek(_ seconds: Double) -> Bool {
guard seconds.isFinite, seconds >= 0, latestStatus?.seekable == true else { return false }
return control(["type": "SEEK", "currentTime": seconds], seeking: true)
}
func setVolume(_ volume: Double) -> Bool {
guard volume.isFinite, (0...1).contains(volume), appTransport != nil else { return false }
return control(["type": "SET_VOLUME", "volume": ["level": volume, "muted": volume == 0]], receiver: true)
}
func setSubtitle(_ index: Int?) -> Bool {
guard index == nil || tracks.indices.contains(index!) else { return false }
return control(["type": "EDIT_TRACKS_INFO", "activeTrackIds": index.map { [tracks[$0].id] } ?? []])
}
private func control(_ command: [String: Any], seeking: Bool = false, receiver: Bool = false) -> Bool {
guard !loading, let transport = appTransport, let mediaSession else { return false }
var command = command
if !receiver { command["mediaSessionId"] = mediaSession }
let id = allocateRequest()
let type = command["type"] as? String
let changingPlayback = type == "PLAY" || type == "PAUSE"
do {
try request(command, namespace: receiver ? CastWire.receiver : CastWire.media,
destination: receiver ? "receiver-0" : transport,
operation: (command["type"] as? String ?? "command").lowercased(),
generation: mediaGeneration, id: id) { [weak self] _, error in
guard let self else { return }
if case .cancelled? = error { return }
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
if self.pendingPlayback == id { self.pendingPlayback = nil; self.publishSeekState() }
if let error { self.onError?(error.localizedDescription) }
}
if seeking { pendingSeek = id; publishSeekState() }
if changingPlayback { pendingPlayback = id; publishSeekState() }
return true
} catch { onError?(error.localizedDescription); return false }
}
private func allocateRequest() -> Int { nextRequest += 1; return nextRequest }
@discardableResult
private func request(_ command: [String: Any], namespace: String, destination: String,
operation: String, generation: UUID? = nil, timeout: Duration? = nil,
id suppliedID: Int? = nil,
completion: @escaping ([String: Any]?, CastError?) -> Void) throws -> Int {
guard pending.count < 32 else { throw CastError.queueFull }
let id = suppliedID ?? allocateRequest()
var command = command
command["requestId"] = id
let sleep = self.sleep
let duration = timeout ?? requestTimeout
let deadline = Task { [weak self] in
do { try await sleep(duration) } catch { return }
self?.complete(id, json: nil, error: .timeout(operation))
}
pending[id] = Pending(namespace: namespace, destination: destination, operation: operation,
generation: generation, completion: completion, deadline: deadline)
do { try send(command, namespace: namespace, destination: destination) }
catch { pending.removeValue(forKey: id)?.deadline.cancel(); throw error }
return id
}
private func complete(_ id: Int, json: [String: Any]?, error: CastError?) {
guard let item = pending.removeValue(forKey: id) else { return }
item.deadline.cancel()
item.completion(json, error)
}
private func cancelMediaRequests() {
let ids = pending.filter { $0.value.generation != nil }.map(\.key)
for id in ids { complete(id, json: nil, error: .cancelled) }
}
private func send(_ json: [String: Any], namespace: String, destination: String) throws {
guard connection != nil else { throw CastError.disconnected }
guard writes.count < 32 else { throw CastError.queueFull }
let payload = try JSONSerialization.data(withJSONObject: json)
writes.append(try CastWire.frame(.init(source: source, destination: destination,
namespace: namespace, payload: payload)))
flush()
}
private func flush() {
guard !writing, let socket = connection, !writes.isEmpty else { return }
writing = true
let data = writes.removeFirst()
let id = connectionID
socket.send(content: data, completion: .contentProcessed { [weak self] error in
Task { @MainActor in
guard let self, self.connectionID == id else { return }
self.writing = false
if error != nil { self.fail(.disconnected) } else { self.flush() }
}
})
}
private func receive(_ socket: NWConnection, id: UUID) {
socket.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, ended, error in
Task { @MainActor in
guard let self, self.connectionID == id else { return }
do {
if let data {
for message in try self.stream.append(data) {
self.handle(message)
guard self.connectionID == id else { return }
}
}
if ended || error != nil { self.fail(.disconnected) }
else { self.receive(socket, id: id) }
} catch { self.fail(.protocolError) }
}
}
}
private func handle(_ message: CastWire.Message) {
guard message.destination == source || message.destination == "*",
let json = try? JSONSerialization.jsonObject(with: message.payload) as? [String: Any],
let type = json["type"] as? String else { return }
if message.namespace == CastWire.heartbeat, message.source == "receiver-0" {
if type == "PONG" { awaitingPong = false }
if type == "PING" {
do { try send(["type": "PONG"], namespace: CastWire.heartbeat, destination: "receiver-0") }
catch { fail(.disconnected) }
}
return
}
if message.namespace == CastWire.connection, type == "CLOSE",
message.source == appTransport || message.source == "receiver-0" {
fail(.disconnected); return
}
let id = json["requestId"] as? Int
let item = id.flatMap { pending[$0] }
let correlated = item?.namespace == message.namespace && item?.destination == message.source &&
(item?.generation == nil || item?.generation == mediaGeneration)
if ["INVALID_REQUEST", "INVALID_PLAYER_STATE", "LOAD_FAILED", "LOAD_CANCELLED", "LAUNCH_ERROR"].contains(type) {
if correlated, let id, let item { complete(id, json: nil, error: .rejected(item.operation)) }
return
}
if message.namespace == CastWire.receiver, message.source == "receiver-0", type == "RECEIVER_STATUS" {
if let status = json["status"] as? [String: Any] {
if let volume = status["volume"] as? [String: Any],
let level = volume["level"] as? Double, level.isFinite {
let muted = volume["muted"] as? Bool ?? false
receiverVolume = muted ? 0 : min(1, max(0, level))
if let latestStatus { publish(latestStatus, volume: receiverVolume) }
}
if let appSession, let appTransport {
let apps = status["applications"] as? [[String: Any]] ?? []
guard apps.contains(where: { $0["sessionId"] as? String == appSession &&
$0["transportId"] as? String == appTransport }) else {
fail(.rejected("receiver ownership changed")); return
}
}
}
if correlated, let id { complete(id, json: json, error: nil) }
return
}
guard message.namespace == CastWire.media, message.source == appTransport,
type == "MEDIA_STATUS", let statuses = json["status"] as? [[String: Any]] else { return }
let olderReply = correlated && id.map { $0 < newestMediaReply } == true
if olderReply, id != pendingSeek, id != pendingPlayback, let id {
complete(id, json: json, error: nil)
return
}
for status in statuses {
guard let session = status["mediaSessionId"] as? Int else { continue }
let media = status["media"] as? [String: Any]
let reportedContent = media?["contentId"] as? String
let customData = media?["customData"] as? [String: Any]
if let nonce = customData?["terebiSession"] as? String, nonce != mediaGeneration?.uuidString {
if retiredLoads.contains(nonce) { continue }
if !loading { fail(.rejected("media ownership changed")); return }
continue
}
if mediaSession == nil {
guard loading, correlated, item?.operation == "media load", reportedContent == contentID,
!retiredSessions.contains(session) else { continue }
mediaSession = session
}
guard session == mediaSession else {
if !retiredSessions.contains(session), !loading {
fail(.rejected("media ownership changed")); return
}
continue
}
if let reportedContent, reportedContent != contentID {
fail(.rejected("media ownership changed")); return
}
let state = status["playerState"] as? String ?? "IDLE"
guard ["IDLE", "PLAYING", "PAUSED", "BUFFERING"].contains(state) else {
fail(.protocolError); return
}
let idle = status["idleReason"] as? String
if state == "IDLE", idle == "ERROR" {
if correlated, let id { complete(id, json: nil, error: .rejected("media playback")) }
fail(.rejected("media playback")); return
}
if state == "IDLE", idle == "INTERRUPTED" || idle == "CANCELLED" {
fail(.rejected("media playback stopped")); return
}
let receivedPosition = status["currentTime"] as? Double ?? latestStatus?.position ?? 0
let position = olderReply && id != pendingSeek ? latestStatus?.position ?? receivedPosition : receivedPosition
let duration = media?["duration"] as? Double ?? latestStatus?.duration ?? loadedMedia?.duration ?? 0
guard position.isFinite, duration.isFinite else { fail(.protocolError); return }
let supported = status["supportedMediaCommands"] as? Int
let active = status["activeTrackIds"] as? [Int]
let subtitle = active.map { ids in tracks.firstIndex { ids.contains($0.id) } } ?? latestStatus?.subtitle
let live = media?["streamType"] as? String == "LIVE" || loadedMedia?.live == true
// A late SEEK still owns its position acknowledgement, but not newer playback intent.
let previous = olderReply && id != pendingPlayback ? latestStatus : nil
// An old status/poll cannot clear a newer seek. Only that seek's reply does.
if correlated, id == pendingSeek { pendingSeek = nil }
if correlated, id == pendingPlayback { pendingPlayback = nil }
let next = CastStatus(position: max(0, position), duration: max(0, duration),
playing: previous?.playing ?? (state == "PLAYING" || state == "BUFFERING"),
buffering: previous?.buffering ?? (state == "BUFFERING"), ended: previous?.ended ?? (state == "IDLE" && idle == "FINISHED"),
volume: receiverVolume,
seekable: !live && (supported.map { $0 & 2 != 0 } ?? latestStatus?.seekable ?? false),
subtitle: subtitle, seekPending: pendingSeek != nil, playbackPending: playbackPending)
latestStatus = next
if correlated, let id { newestMediaReply = max(newestMediaReply, id) }
onStatus?(next)
if correlated, let id { complete(id, json: json, error: nil) }
return
}
}
private func publish(_ status: CastStatus, volume: Double? = nil) {
let next = CastStatus(position: status.position, duration: status.duration, playing: status.playing,
buffering: status.buffering, ended: status.ended, volume: volume ?? status.volume,
seekable: status.seekable, subtitle: status.subtitle, seekPending: pendingSeek != nil,
playbackPending: playbackPending)
latestStatus = next
onStatus?(next)
}
private func publishSeekState() { if let latestStatus { publish(latestStatus) } }
private func startHeartbeat() {
heartbeatTask?.cancel()
let id = connectionID
heartbeatTask = Task { [weak self] in
while !Task.isCancelled {
guard let self, self.connectionID == id else { return }
do { try await self.sleep(self.heartbeatInterval) } catch { return }
guard self.connectionID == id else { return }
if self.awaitingPong { self.fail(.timeout("heartbeat")); return }
self.awaitingPong = true
do {
try self.send(["type": "PING"], namespace: CastWire.heartbeat, destination: "receiver-0")
// Receivers do not reliably broadcast clock progress. Poll without
// extrapolating receiver time, allowing only one outstanding poll.
if !self.loading, let transport = self.appTransport, let session = self.mediaSession,
self.pending[self.lastStatusPoll] == nil {
self.lastStatusPoll = try self.request(["type": "GET_STATUS", "mediaSessionId": session],
namespace: CastWire.media, destination: transport, operation: "media status",
generation: self.mediaGeneration) { [weak self] _, error in
if case .cancelled? = error { return }
if let error { self?.onError?(error.localizedDescription) }
}
}
} catch { self.fail(error as? CastError ?? .disconnected); return }
}
}
}
func disconnect(stopReceiver: Bool = true) { teardown(error: nil, stopReceiver: stopReceiver) }
private func fail(_ error: CastError) { teardown(error: error, stopReceiver: false) }
private func teardown(error: CastError?, stopReceiver: Bool) {
let socket = connection
let connected = socket != nil
var farewell = Data()
if connected {
func append(_ json: [String: Any], namespace: String, destination: String) {
if let payload = try? JSONSerialization.data(withJSONObject: json),
let frame = try? CastWire.frame(.init(source: source, destination: destination,
namespace: namespace, payload: payload)) { farewell.append(frame) }
}
// Stop only the media session we acquired, never STOP the application: another
// sender may be using the Default Media Receiver by the time we disconnect.
if stopReceiver, let mediaSession, let appTransport, !loading {
append(["type": "STOP", "mediaSessionId": mediaSession, "requestId": allocateRequest()],
namespace: CastWire.media, destination: appTransport)
}
if let appTransport { append(["type": "CLOSE"], namespace: CastWire.connection, destination: appTransport) }
append(["type": "CLOSE"], namespace: CastWire.connection, destination: "receiver-0")
}
connectionID = UUID()
connection = nil
socket?.stateUpdateHandler = nil
readyDeadline?.cancel(); readyDeadline = nil
heartbeatTask?.cancel(); heartbeatTask = nil
replayTask?.cancel(); replayTask = nil
awaitingPong = false
let ready = self.ready
self.ready = nil
let requests = pending.values
pending.removeAll()
writes.removeAll(); writing = false
appTransport = nil; appSession = nil; mediaSession = nil
retiredSessions.removeAll(); contentID = nil; mediaGeneration = nil
retiredLoads.removeAll()
tracks.removeAll(); latestStatus = nil; pendingSeek = nil; loading = false
pendingPlayback = nil
loadedMedia = nil; loadedTitle = ""
receiverVolume = 1
ready?.resume(throwing: error ?? CastError.cancelled)
for request in requests {
request.deadline.cancel()
request.completion(nil, error ?? .cancelled)
}
if let socket {
if !farewell.isEmpty, error == nil {
socket.send(content: farewell, completion: .contentProcessed { _ in socket.cancel() })
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { socket.cancel() }
} else { socket.cancel() }
}
if connected { onDisconnect?(error?.localizedDescription) }
}
}
+8 -4
View File
@@ -24,6 +24,13 @@ struct WebVTTDocument: Sendable {
}
static func load(url: String, headers: String) async throws -> WebVTTDocument {
let data = try await loadData(url: url, headers: headers)
// Providers sometimes disguise VTT resources as images: trust the explicit format and content.
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
return try parse(text)
}
static func loadData(url: String, headers: String) async throws -> Data {
let request = try request(url: url, headers: headers)
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 15
@@ -51,10 +58,7 @@ struct WebVTTDocument: Sendable {
if Task.isCancelled { throw CancellationError() }
throw SubtitleError.transport
}
// Providers sometimes disguise VTT resources as images: trust the explicit format and content,
// never the path extension or Content-Type.
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
return try parse(text)
return data
}
static func parse(_ text: String) throws -> WebVTTDocument {
+2
View File
@@ -190,6 +190,8 @@ struct PlayerCommands: Commands {
.disabled(focusedModel == nil)
}
CommandMenu("Playback") {
CastDeviceMenu(model: model, icon: false)
Divider()
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(playback))
+263 -11
View File
@@ -28,6 +28,19 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String?
private var transition = PlaybackTransition()
let castDiscovery = CastDiscovery()
@Published private(set) var castDevice: CastDevice?
@Published private(set) var castConnecting = false
private var castActive = false
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?
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive }
@@ -56,7 +69,7 @@ final class PlayerModel: ObservableObject {
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
}
var playbackIntent: Bool? { transition.playbackIntent }
var playbackIntent: Bool? { castActive ? castIntent : transition.playbackIntent }
@Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool?
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
@@ -149,19 +162,23 @@ final class PlayerModel: ObservableObject {
systemPlayback = SystemPlaybackControls(model: self)
}
var isBusy: Bool { transition.isBusy(state.status) }
var isBusy: Bool { castConnecting || (!castActive && transition.isBusy(state.status)) }
var canTogglePlayback: Bool {
handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
}
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
var canStepFrame: Bool { castDevice == nil && canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
var canChangeYouTubeQuality: Bool {
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
castDevice == nil && canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
}
var canOpen: Bool { handle != nil }
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
var canCast: Bool {
castDevice == nil && currentMedia != nil && canTogglePlayback && !isSeeking && !state.seek_pending &&
!transition.seekPending && transition.playbackIntent == nil && !castConnecting
}
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
var bufferedRange: ClosedRange<Double>? {
guard fileName != nil, canSeek, !transition.seekPending, !state.seek_pending,
@@ -180,6 +197,175 @@ final class PlayerModel: ObservableObject {
}
}
func cast(to device: CastDevice) {
guard canCast, let source = currentMedia, let handle else { return }
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
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) {
guard castDevice != nil else { return }
saveProgress()
let source = currentMedia
let wasActive = castActive
let position = state.can_seek && !isLiveStream && state.status != PLAYER_ENDED ? state.position : 0
let playing = wasActive ? effectivePlaying : castResumePlaying
if wasActive, let handle {
transition.suspendHistory(revision: player_get_state(handle).revision)
}
castRequest = nil
castTask?.cancel()
castTask = nil
castDevice = nil
castConnecting = false
castActive = false
castIntent = nil
let session = castSession
castSession = nil
session?.disconnect()
castServer?.stop()
castServer = nil
volume = castLocalVolume
lastAudibleVolume = castLocalAudibleVolume
if let handle { _ = player_set_volume(handle, Float(volume)) }
if resume, let source {
if wasActive {
openMedia(source, change: .restore(position: position, playing: playing))
} else if let handle {
_ = playing ? player_play(handle) : player_pause(handle)
}
}
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
private func seekCast(to position: Double) -> Bool {
let accepted = castSession?.seek(position) == true
if accepted { state.seek_pending = true; seekPosition = position }
commandError = accepted ? nil : "Could not seek Cast playback."
revealControls()
return accepted
}
func openPanel() {
guard canOpen else { return }
defer { window?.makeFirstResponder(nil) }
@@ -236,6 +422,10 @@ final class PlayerModel: ObservableObject {
}
func selectSubtitle(_ index: Int?) {
if castActive {
if castSession?.setSubtitle(index) != true { commandError = "Could not change Cast subtitles." }
return
}
subtitleTask?.cancel()
subtitleTask = nil
subtitleDocument = nil
@@ -258,6 +448,7 @@ final class PlayerModel: ObservableObject {
}
private func updateSubtitle() {
if castDevice != nil { subtitleText = ""; return }
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
subtitleDocument?.text(at: state.position) ?? ""
@@ -292,6 +483,7 @@ final class PlayerModel: ObservableObject {
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
cancelYouTubeResolution()
if !change.automatic { automaticQuality = AutomaticQuality() }
if !change.automatic && !player_pause(handle) {
@@ -351,9 +543,10 @@ final class PlayerModel: ObservableObject {
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
saveProgress()
let revision = player_get_state(handle).revision
let savedPosition = change.isQuality ? change.position :
let savedPosition = change.preservesPosition ? change.position :
(source.live ? nil : playbackProgress.position(for: source.url))
let accepted: Bool
switch source {
@@ -407,6 +600,7 @@ final class PlayerModel: ObservableObject {
return
}
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
currentMedia = source
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source {
case .file(let url):
@@ -462,6 +656,14 @@ final class PlayerModel: ObservableObject {
@discardableResult
func setPlayback(playing: Bool) -> Bool {
if castActive {
guard canTogglePlayback else { return false }
let accepted = castSession?.setPlaying(playing) == true
if accepted { castIntent = playing; objectWillChange.send() }
commandError = accepted ? nil : "Could not change Cast playback."
revealControls()
return accepted
}
guard let handle, canTogglePlayback else { return false }
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() }
@@ -484,6 +686,10 @@ final class PlayerModel: ObservableObject {
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
if castActive {
seekCast(to: min(max(0, seekPosition), duration))
return
}
let position = min(max(0, seekPosition), duration)
let accepted = transition.seek(position, handle: handle)
commandError = accepted ? nil : "Could not queue the seek. Please try again."
@@ -493,6 +699,7 @@ final class PlayerModel: ObservableObject {
func closeFile() {
guard let handle, canClose else { return }
saveProgress()
if castDevice != nil { stopCasting(resume: false) }
cancelYouTubeResolution()
automaticQuality = AutomaticQuality()
guard player_close(handle) else {
@@ -500,6 +707,7 @@ final class PlayerModel: ObservableObject {
return
}
transition.closeMedia()
currentMedia = nil
selectSubtitle(nil)
subtitleTracks = []
compactIntent = nil
@@ -524,8 +732,8 @@ final class PlayerModel: ObservableObject {
guard canSeek, let handle, seconds.isFinite else { return false }
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
let target = min(max(0, (transition.seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = transition.seek(target, handle: handle)
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)
if accepted { seekPosition = target }
commandError = accepted ? nil : "Could not queue the seek. Please try again."
if !accepted { revealControls() }
@@ -542,6 +750,14 @@ final class PlayerModel: ObservableObject {
}
func setVolume(_ value: Double) {
if castActive {
if value.isFinite, (0...1).contains(value), castSession?.setVolume(value) == true {
volume = value
if volume > 0 { lastAudibleVolume = volume }
}
revealControls()
return
}
guard let handle else { return }
if player_set_volume(handle, Float(value)) {
volume = Double(player_get_volume(handle))
@@ -757,7 +973,7 @@ final class PlayerModel: ObservableObject {
}
private var shouldHideControls: Bool {
((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
castDevice == nil && ((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
(!pointerInsideWindow && isBusy)) &&
!isSeeking && !transition.seekPending && !controlsDragging &&
(!pointerInsideWindow || (!controlsHovered && !titleHovered)) &&
@@ -796,8 +1012,14 @@ final class PlayerModel: ObservableObject {
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle else { return }
if castActive {
guard let url = currentMedia?.url, !isLiveStream, 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: url)
} else {
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
}
}
private func poll() {
guard let handle else { return }
@@ -806,7 +1028,7 @@ final class PlayerModel: ObservableObject {
showReportingError(reportingErrorMessage)
}
}
applyState(player_get_state(handle))
if !castActive { applyState(player_get_state(handle)) }
}
func applyState(_ next: PlayerState) {
@@ -836,6 +1058,7 @@ final class PlayerModel: ObservableObject {
}
private func updateAutomaticQuality() {
guard castDevice == nil else { return }
guard automaticYouTubeQuality else { return }
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample(
@@ -865,6 +1088,8 @@ final class PlayerModel: ObservableObject {
func shutdown() {
saveProgress()
if castDevice != nil { stopCasting(resume: false) }
castDiscovery.stop()
cancelYouTubeResolution()
selectSubtitle(nil)
systemPlayback?.shutdown()
@@ -887,6 +1112,7 @@ final class PlayerModel: ObservableObject {
private enum MediaChange {
case open
case quality(position: Double?, playing: Bool, automatic: Bool)
case restore(position: Double, playing: Bool)
var automatic: Bool {
if case .quality(_, _, let automatic) = self { return automatic }
@@ -894,16 +1120,22 @@ final class PlayerModel: ObservableObject {
}
var playing: Bool {
if case .quality(_, let playing, _) = self { return playing }
if case .restore(_, let playing) = self { return playing }
return true
}
var position: Double? {
if case .quality(let position, _, _) = self { return position }
if case .restore(let position, _) = self { return position }
return nil
}
var isQuality: Bool {
if case .quality = self { return true }
return false
}
var preservesPosition: Bool {
if case .restore = self { return true }
return isQuality
}
}
private enum MediaSource {
@@ -1067,6 +1299,15 @@ final class PlayerModel: ObservableObject {
return accepted
}
mutating func acceptCast() {
playback = nil
pendingRevision = nil
restoration = nil
seekPending = false
}
mutating func suspendHistory(revision: UInt64) {
pendingRevision = revision
}
mutating func closeMedia() {
playback = nil
isLive = false
@@ -1539,6 +1780,7 @@ private struct PlayerContent: View {
private var titleControls: some View {
HStack(spacing: 4) {
CastDeviceMenu(model: model)
iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.imageScale(.small)
@@ -1610,6 +1852,7 @@ private struct PlayerContent: View {
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
.disabled(!model.canToggleCompact)
actionsMenu
CastDeviceMenu(model: model)
}
if showsBufferReadout { bufferReadout }
}
@@ -1725,7 +1968,7 @@ private struct PlayerContent: View {
if let view = model.videoView {
VideoSurface(view: view)
.accessibilityLabel("Video")
.opacity(model.fileName == nil ? 0 : 1)
.opacity(model.fileName == nil || model.castDevice != nil ? 0 : 1)
.gesture(
TapGesture(count: 2).onEnded { model.toggleFullScreen() }
.exclusively(before: TapGesture().onEnded { model.hideControls() })
@@ -1775,7 +2018,8 @@ private struct PlayerContent: View {
} else if model.isBusy {
VStack(spacing: 12) {
ProgressView().controlSize(.large)
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" :
(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…"))
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -1865,6 +2109,14 @@ private struct PlayerContent: View {
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 32)
} else if let device = model.castDevice {
VStack(spacing: 16) {
Image(systemName: "tv.badge.wifi").font(.system(size: 48))
Text("Casting to \(device.name)").font(.headline)
Button("Play on This Mac") { model.stopCasting() }
}
.foregroundStyle(Color(SunsetPalette.text))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if !model.state.has_video && model.state.has_audio {
VStack(spacing: 16) {
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
+758
View File
@@ -0,0 +1,758 @@
#include "cast_media.h"
#include "network_policy.hpp"
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/audio_fifo.h>
#include <libavutil/opt.h>
#include <libswresample/swresample.h>
#include <libswscale/swscale.h>
}
#include <algorithm>
#include <array>
#include <atomic>
#include <charconv>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <map>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#include <netdb.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>
#include <limits>
namespace {
using Clock = std::chrono::steady_clock;
constexpr double segmentSeconds = 6;
constexpr size_t segmentLimit = 32 * 1024 * 1024;
constexpr size_t windowSegments = 8;
void check(int value) { if (value < 0) throw std::runtime_error("Cast media operation failed (private details redacted)."); }
void require(bool value) { if (!value) throw std::runtime_error("Cast media is unavailable or unsupported (private details redacted)."); }
void privateLog(void *, int level, const char *, va_list) {
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg cast error (private details redacted)\n" :
"FFmpeg cast warning (private details redacted)\n", stderr);
}
struct FrameDelete { void operator()(AVFrame *p) const { av_frame_free(&p); } };
struct PacketDelete { void operator()(AVPacket *p) const { av_packet_free(&p); } };
using Frame = std::unique_ptr<AVFrame, FrameDelete>;
using Packet = std::unique_ptr<AVPacket, PacketDelete>;
Frame frame() { auto p = Frame(av_frame_alloc()); require(bool(p)); return p; }
Packet packet() { auto p = Packet(av_packet_alloc()); require(bool(p)); return p; }
int64_t aacBoundary(double seconds) { return int64_t(std::ceil(seconds * 48000 / 1024)) * 1024; }
double aacPreroll(double seconds) { return double(std::max<int64_t>(0, aacBoundary(seconds) - 12 * 1024)) / 48000; }
bool hasIDR(const AVPacket &packet, const AVCodecParameters &codec) {
const auto *bytes = packet.data;
const size_t size = size_t(std::max(0, packet.size));
if (codec.extradata_size >= 5 && codec.extradata[0] == 1) {
const size_t lengthSize = (codec.extradata[4] & 3) + 1;
for (size_t at = 0; at + lengthSize <= size;) {
uint32_t length = 0;
for (size_t i = 0; i < lengthSize; ++i) length = (length << 8) | bytes[at++];
if (!length || length > size - at) return false;
if ((bytes[at] & 31) == 5) return true;
at += length;
}
return false;
}
for (size_t at = 0; at + 3 < size; ++at) {
if (bytes[at] == 0 && bytes[at + 1] == 0 && bytes[at + 2] == 1 &&
(bytes[at + 3] & 31) == 5) return true;
}
return false;
}
struct Source {
std::string video, videoHeaders, audio, audioHeaders;
bool local, restricted, live;
double duration;
};
struct Input {
AVFormatContext *format = nullptr;
std::atomic<bool> &cancel;
std::string origin, headers;
std::atomic<int64_t> deadline{0};
int64_t operationDeadline = std::numeric_limits<int64_t>::max();
bool network, restricted, live;
Input(std::atomic<bool> &stop, const std::string &url, const std::string &fields, bool local, bool restricted, bool live) :
cancel(stop), headers(fields), network(!local), restricted(restricted), live(live) {
origin = local ? "" : playback::networkOrigin(url.c_str());
format = avformat_alloc_context(); require(format != nullptr);
format->interrupt_callback = {interrupt, this};
if (network) { format->opaque = this; format->io_open = openResource; }
AVDictionary *options = nullptr;
auto set = [&](const char *key, const char *value) {
int result = av_dict_set(&options, key, value, 0);
if (result < 0) { av_dict_free(&options); avformat_close_input(&format); check(result); }
};
set("protocol_whitelist", local ? "file" : "http,https,tcp,tls,crypto");
set("rw_timeout", live ? "15000000" : "5000000");
set("probesize", "5242880");
set("analyzeduration", "5000000");
if (network) {
if (!headers.empty()) set("headers", headers.c_str());
set("max_redirects", "0");
set("http_persistent", "0");
set("http_multiple", "0");
set("extension_picky", "0");
set("request_size", "10485760");
}
arm();
int result = avformat_open_input(&format, url.c_str(), nullptr, &options);
av_dict_free(&options);
if (result >= 0) { arm(); result = avformat_find_stream_info(format, nullptr); }
if (result < 0) { avformat_close_input(&format); check(result); }
}
~Input() { avformat_close_input(&format); }
void arm() {
const auto ioDeadline = std::chrono::duration_cast<std::chrono::nanoseconds>((Clock::now() + std::chrono::seconds(15)).time_since_epoch()).count();
deadline = std::min<int64_t>(ioDeadline, operationDeadline);
}
static int interrupt(void *opaque) {
auto &in = *static_cast<Input *>(opaque);
return in.cancel || std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now().time_since_epoch()).count() > in.deadline;
}
static int openResource(AVFormatContext *context, AVIOContext **io, const char *url, int flags, AVDictionary **options) noexcept {
try {
auto &in = *static_cast<Input *>(context->opaque);
const char *resource = std::strncmp(url, "crypto+", 7) == 0 || std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
const auto resourceOrigin = playback::networkOrigin(resource);
if (resourceOrigin.empty() || (in.restricted && resourceOrigin != in.origin)) return AVERROR(EACCES);
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
// Public YouTube HLS may cross CDN origins; never carry private headers across them.
if (result >= 0) result = av_dict_set(options, "headers", resourceOrigin == in.origin && !in.headers.empty() ? in.headers.c_str() : nullptr, 0);
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
if (result >= 0) result = av_dict_set(options, "rw_timeout", in.live ? "15000000" : "5000000", 0);
if (result < 0) return result;
return avio_open2(io, url, flags, &context->interrupt_callback, options);
} catch (...) { return AVERROR(EACCES); }
}
double offset() const { return format->start_time == AV_NOPTS_VALUE ? 0 : double(format->start_time) / AV_TIME_BASE; }
double duration() const { return format->duration == AV_NOPTS_VALUE ? 0 : double(format->duration) / AV_TIME_BASE; }
void seek(double seconds) {
arm(); check(av_seek_frame(format, -1, int64_t((seconds + offset()) * AV_TIME_BASE), AVSEEK_FLAG_BACKWARD));
}
};
struct Track {
std::unique_ptr<Input> input;
AVStream *stream = nullptr;
AVCodecContext *decoder = nullptr;
Packet pending;
Frame decoded;
bool eof = false, draining = false, remux = false;
double nextTime = 0;
Track(std::unique_ptr<Input> in, AVMediaType type, double start, bool live) : input(std::move(in)) {
int index = av_find_best_stream(input->format, type, -1, -1, nullptr, 0);
if (index < 0) return;
stream = input->format->streams[index];
remux = stream->codecpar->codec_id == (type == AVMEDIA_TYPE_VIDEO ? AV_CODEC_ID_H264 : AV_CODEC_ID_AAC);
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 = remux && stream->codecpar->ch_layout.nb_channels <= 2 &&
(stream->codecpar->profile == AV_PROFILE_AAC_LOW || stream->codecpar->profile == AV_PROFILE_AAC_HE || stream->codecpar->profile == AV_PROFILE_AAC_HE_V2);
}
const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ?
std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) /
std::max(1, stream->codecpar->sample_rate)) : start;
if (!live) input->seek(seekTarget);
nextPacket();
// Every VOD segment must start at its advertised time and on an IDR. If the
// source GOP doesn't align, decode preroll and use VideoToolbox, not overlap.
if (type == AVMEDIA_TYPE_VIDEO)
remux = remux && !live && stream->codecpar->video_delay == 0 && pending &&
(pending->flags & AV_PKT_FLAG_KEY) && hasIDR(*pending, *stream->codecpar) &&
std::abs(packetTime() - start) < .001;
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) {
// ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow.
input->seek(0);
nextPacket();
}
if (!remux) {
const auto *codec = avcodec_find_decoder(stream->codecpar->codec_id); require(codec != nullptr);
decoder = avcodec_alloc_context3(codec); require(decoder != nullptr);
try {
require(stream->codecpar->width >= 0 && stream->codecpar->height >= 0 &&
int64_t(stream->codecpar->width) * stream->codecpar->height <= 4096LL * 2160);
check(avcodec_parameters_to_context(decoder, stream->codecpar));
decoder->pkt_timebase = stream->time_base;
decoder->thread_count = 2;
decoder->max_pixels = 4096LL * 2160;
check(avcodec_open2(decoder, codec, nullptr));
} catch (...) { avcodec_free_context(&decoder); throw; }
}
}
~Track() { avcodec_free_context(&decoder); }
double packetTime() const {
if (!pending) return INFINITY;
auto pts = pending->pts != AV_NOPTS_VALUE ? pending->pts : pending->dts;
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
}
void nextPacket() {
pending.reset();
if (eof || !stream) return;
auto p = packet();
input->arm();
for (;;) {
require(!Input::interrupt(input.get()));
int result = av_read_frame(input->format, p.get());
if (result == AVERROR_EOF) { eof = true; return; }
check(result);
require(p->size >= 0 && size_t(p->size) <= segmentLimit);
if (p->stream_index == stream->index) { pending = std::move(p); return; }
av_packet_unref(p.get());
}
}
double frameTime() const {
if (!decoded) return INFINITY;
auto pts = decoded->best_effort_timestamp;
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
}
void nextFrame() {
decoded.reset(); auto f = frame();
for (;;) {
require(!Input::interrupt(input.get()));
int result = avcodec_receive_frame(decoder, f.get());
if (result == 0) {
decoded = std::move(f);
const double step = stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO ? double(decoded->nb_samples) / decoded->sample_rate : 1.0 / 30;
nextTime = frameTime() + step;
return;
}
if (result == AVERROR_EOF) return;
if (result != AVERROR(EAGAIN)) check(result);
if (!pending) {
if (draining) return;
check(avcodec_send_packet(decoder, nullptr)); draining = true;
} else {
check(avcodec_send_packet(decoder, pending.get())); nextPacket();
}
}
}
double time() { if (remux) return packetTime(); if (!decoded) nextFrame(); return frameTime(); }
};
struct Segment { int64_t id; double duration; std::vector<uint8_t> bytes; };
struct Mux {
AVFormatContext *format = nullptr;
AVIOContext *io = nullptr;
std::vector<uint8_t> bytes;
Mux() {
check(avformat_alloc_output_context2(&format, nullptr, "mpegts", nullptr));
auto *buffer = static_cast<uint8_t *>(av_malloc(32768));
if (!buffer) { avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
io = avio_alloc_context(buffer, 32768, 1, this, nullptr, writeBytes, nullptr);
if (!io) { av_free(buffer); avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
format->pb = io; format->flags |= AVFMT_FLAG_CUSTOM_IO;
}
~Mux() { if (io) { av_freep(&io->buffer); avio_context_free(&io); } avformat_free_context(format); }
static int writeBytes(void *opaque, const uint8_t *data, int count) {
auto &self = *static_cast<Mux *>(opaque);
if (count < 0 || self.bytes.size() + size_t(count) > segmentLimit) return AVERROR(ENOSPC);
try { self.bytes.insert(self.bytes.end(), data, data + count); return count; } catch (...) { return AVERROR(ENOMEM); }
}
};
struct OutputTrack {
Track &source;
Mux *mux;
AVStream *stream = nullptr;
AVCodecContext *encoder = nullptr;
SwsContext *scale = nullptr;
SwrContext *resample = nullptr;
AVAudioFifo *fifo = nullptr;
int64_t audioPTS = AV_NOPTS_VALUE;
bool firstVideo = true;
double lastVideoTime = -INFINITY;
bool continuous;
int64_t audioKeepFrom = AV_NOPTS_VALUE, audioKeepUntil = INT64_MAX;
OutputTrack(Track &track, Mux &output, bool live) : source(track), mux(&output), continuous(live) {
try {
if (!source.stream) return;
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
if (source.remux) {
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
return;
}
bool video = source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO;
const auto *codec = video ? avcodec_find_encoder_by_name("h264_videotoolbox") : avcodec_find_encoder(AV_CODEC_ID_AAC);
require(codec != nullptr); encoder = avcodec_alloc_context3(codec); require(encoder != nullptr);
if (video) {
require(source.decoder->width > 0 && source.decoder->height > 0 &&
int64_t(source.decoder->width) * source.decoder->height <= 4096LL * 2160);
auto sar = av_guess_sample_aspect_ratio(source.input->format, source.stream, nullptr);
if (sar.num <= 0 || sar.den <= 0) sar = {1, 1};
const double displayWidth = source.decoder->width * av_q2d(sar);
const double ratio = std::min({1.0, 1920.0 / displayWidth, 1080.0 / source.decoder->height});
encoder->width = int(displayWidth * ratio) & ~1;
encoder->height = int(source.decoder->height * ratio) & ~1;
require(encoder->width > 0 && encoder->height > 0);
// VideoToolbox can omit SAR in its H264 bitstream; encode square pixels instead.
encoder->sample_aspect_ratio = {1, 1};
encoder->pix_fmt = AV_PIX_FMT_NV12; encoder->time_base = {1, 90000};
encoder->framerate = av_guess_frame_rate(source.input->format, source.stream, nullptr);
if (encoder->framerate.num <= 0 || av_q2d(encoder->framerate) > 30) encoder->framerate = {30, 1};
encoder->bit_rate = 6000000; encoder->gop_size = 180; encoder->max_b_frames = 0;
check(av_opt_set(encoder->priv_data, "profile", "main", 0));
check(av_opt_set(encoder->priv_data, "level", "4.1", 0));
// VideoToolbox may use its platform software fallback, never a host executable.
av_opt_set(encoder->priv_data, "allow_sw", "1", 0);
av_opt_set(encoder->priv_data, "realtime", "1", 0);
} else {
encoder->sample_rate = 48000; encoder->sample_fmt = AV_SAMPLE_FMT_FLTP;
av_channel_layout_default(&encoder->ch_layout, 2); encoder->time_base = {1, 48000}; encoder->bit_rate = 192000;
fifo = av_audio_fifo_alloc(encoder->sample_fmt, 2, 4096); require(fifo != nullptr);
}
check(avcodec_open2(encoder, codec, nullptr));
stream->time_base = encoder->time_base; check(avcodec_parameters_from_context(stream->codecpar, encoder));
} catch (...) { avcodec_free_context(&encoder); av_audio_fifo_free(fifo); fifo = nullptr; throw; }
}
~OutputTrack() { avcodec_free_context(&encoder); sws_freeContext(scale); swr_free(&resample); av_audio_fifo_free(fifo); }
void beginSegment(Mux &output) {
mux = &output;
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
if (source.remux) {
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
} else {
stream->time_base = encoder->time_base;
check(avcodec_parameters_from_context(stream->codecpar, encoder));
}
firstVideo = true;
}
void writePacket(AVPacket *packet) {
// A shared, positive TS clock origin prevents per-segment AAC priming shifts.
const auto offset = av_rescale_q(1, AVRational{1, 1}, stream->time_base);
if (packet->pts != AV_NOPTS_VALUE) packet->pts += offset;
if (packet->dts != AV_NOPTS_VALUE) packet->dts += offset;
packet->stream_index = stream->index;
check(av_interleaved_write_frame(mux->format, packet));
}
void encoded(AVFrame *f) {
check(avcodec_send_frame(encoder, f)); auto p = packet();
for (;;) {
int result = avcodec_receive_packet(encoder, p.get());
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break;
check(result);
if (fifo && !continuous && audioKeepFrom != AV_NOPTS_VALUE &&
(p->pts < audioKeepFrom || p->pts >= audioKeepUntil)) {
av_packet_unref(p.get());
continue;
}
av_packet_rescale_ts(p.get(), encoder->time_base, stream->time_base);
writePacket(p.get()); av_packet_unref(p.get());
}
}
void audioFrames(bool finish, double end = INFINITY) {
const int count = encoder->frame_size;
while ((av_audio_fifo_size(fifo) >= count || (finish && av_audio_fifo_size(fifo) > 0)) &&
(audioPTS == AV_NOPTS_VALUE || double(audioPTS) / 48000 < end)) {
auto f = frame(); f->format = encoder->sample_fmt; f->sample_rate = encoder->sample_rate;
check(av_channel_layout_copy(&f->ch_layout, &encoder->ch_layout)); f->nb_samples = count;
check(av_frame_get_buffer(f.get(), 0));
int available = std::min(count, av_audio_fifo_size(fifo));
require(av_audio_fifo_read(fifo, reinterpret_cast<void **>(f->data), available) == available);
if (available < count) av_samples_set_silence(f->data, available, count - available, 2, encoder->sample_fmt);
f->pts = audioPTS; audioPTS += count; encoded(f.get());
}
}
void consume(double start, double end) {
if (source.remux) {
double time = source.packetTime();
if (time >= start - .001 && time < end) {
auto *p = source.pending.get();
int64_t offset = int64_t(source.input->offset() / av_q2d(source.stream->time_base));
if (p->pts != AV_NOPTS_VALUE) p->pts -= offset;
if (p->dts != AV_NOPTS_VALUE) p->dts -= offset;
av_packet_rescale_ts(p, source.stream->time_base, stream->time_base); p->stream_index = stream->index;
writePacket(p);
}
source.nextPacket(); return;
}
auto *f = source.decoded.get(); double time = source.frameTime();
if (source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
if (time >= start - .001 && time < end && time - lastVideoTime >= 1.0 / 30 - .00001) {
require(f->width > 0 && f->height > 0 && int64_t(f->width) * f->height <= 4096LL * 2160);
auto out = frame(); out->format = encoder->pix_fmt; out->width = encoder->width; out->height = encoder->height;
out->sample_aspect_ratio = encoder->sample_aspect_ratio;
check(av_frame_get_buffer(out.get(), 32));
scale = sws_getCachedContext(scale, f->width, f->height, AVPixelFormat(f->format), out->width, out->height, encoder->pix_fmt, SWS_BILINEAR, nullptr, nullptr, nullptr);
require(scale != nullptr); check(sws_scale(scale, f->data, f->linesize, 0, f->height, out->data, out->linesize));
out->pts = int64_t(std::llround(std::max(start, time) * 90000));
out->pict_type = firstVideo ? AV_PICTURE_TYPE_I : AV_PICTURE_TYPE_NONE; firstVideo = false; encoded(out.get());
lastVideoTime = time;
}
} else {
require(f->sample_rate > 0 && f->nb_samples >= 0 && f->nb_samples <= 65536 && f->ch_layout.nb_channels <= 32);
const double begin = continuous ? start : aacPreroll(start);
const double limit = continuous ? end : double(aacBoundary(end)) / 48000;
if (!continuous) {
audioKeepFrom = aacBoundary(start);
audioKeepUntil = int64_t(std::llround(end * 48000));
}
if (time + double(f->nb_samples) / f->sample_rate > begin && time < limit) {
if (!resample) {
check(swr_alloc_set_opts2(&resample, &encoder->ch_layout, encoder->sample_fmt, 48000, &f->ch_layout, AVSampleFormat(f->format), f->sample_rate, 0, nullptr));
check(swr_init(resample));
}
int capacity = int(av_rescale_rnd(swr_get_delay(resample, f->sample_rate) + f->nb_samples, 48000, f->sample_rate, AV_ROUND_UP));
require(capacity > 0 && capacity <= 131072);
auto out = frame(); out->format = encoder->sample_fmt; out->sample_rate = 48000;
check(av_channel_layout_copy(&out->ch_layout, &encoder->ch_layout)); out->nb_samples = capacity; check(av_frame_get_buffer(out.get(), 0));
int count = swr_convert(resample, out->data, capacity, const_cast<const uint8_t **>(f->extended_data), f->nb_samples); check(count);
int skip = continuous && audioPTS != AV_NOPTS_VALUE ? 0 :
std::clamp(int(std::llround((begin - time) * 48000)), 0, count);
int take = continuous ? count - skip :
std::clamp(int(std::llround((limit - time) * 48000)) - skip, 0, count - skip);
if (audioPTS == AV_NOPTS_VALUE) audioPTS = int64_t(std::llround(std::max(begin, time) * 48000));
uint8_t *data[2] = {out->data[0] + skip * 4, out->data[1] + skip * 4};
require(av_audio_fifo_size(fifo) + take <= 131072);
require(av_audio_fifo_write(fifo, reinterpret_cast<void **>(data), take) == take); audioFrames(false, end);
}
}
source.nextFrame();
}
void finish(double end) {
if (!encoder) return;
if (continuous) {
if (fifo) audioFrames(false, end);
} else {
if (fifo) audioFrames(true);
encoded(nullptr);
}
}
};
struct Pipeline {
std::unique_ptr<Track> video, audio;
std::unique_ptr<OutputTrack> videoOutput, audioOutput;
bool live;
std::atomic<bool> &cancel;
double duration = 0;
Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) {
auto primary = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
duration = std::max(source.duration, primary->duration());
bool hasVideo = av_find_best_stream(primary->format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0) >= 0;
bool hasAudio = av_find_best_stream(primary->format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0) >= 0;
if (hasVideo) video = std::make_unique<Track>(std::move(primary), AVMEDIA_TYPE_VIDEO, start, source.live);
if (!source.audio.empty()) {
audio = std::make_unique<Track>(std::make_unique<Input>(stop, source.audio, source.audioHeaders, false, source.restricted, source.live), AVMEDIA_TYPE_AUDIO, start, source.live);
require(audio->stream != nullptr);
} else if (hasAudio) {
if (!primary) primary = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
audio = std::make_unique<Track>(std::move(primary), AVMEDIA_TYPE_AUDIO, start, source.live);
}
require(video || audio);
if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14);
}
Segment segment(int64_t id, double start, double end) {
Mux mux;
if (video) {
videoOutput.reset();
videoOutput = std::make_unique<OutputTrack>(*video, mux, false);
}
if (audio) {
if (audioOutput) audioOutput->beginSegment(mux);
else audioOutput = std::make_unique<OutputTrack>(*audio, mux, live);
}
AVDictionary *options = nullptr;
av_dict_set(&options, "mpegts_flags", "resend_headers", 0);
av_dict_set(&options, "mpegts_copyts", "1", 0);
av_dict_set(&options, "muxdelay", "0", 0);
int result = avformat_write_header(mux.format, &options); av_dict_free(&options); check(result);
const auto deadline = Clock::now() + std::chrono::seconds(45);
const auto limit = std::chrono::duration_cast<std::chrono::nanoseconds>(deadline.time_since_epoch()).count();
if (video) video->input->operationDeadline = limit;
if (audio) audio->input->operationDeadline = limit;
const double audioEnd = audio && !audio->remux && !live ? double(aacBoundary(end)) / 48000 : end;
for (;;) {
require(!cancel && Clock::now() < deadline);
double vt = video ? video->time() : INFINITY, at = audio ? audio->time() : INFINITY;
if (vt >= end) vt = INFINITY;
if (at >= audioEnd) at = INFINITY;
if (!std::isfinite(vt) && !std::isfinite(at)) break;
if (vt <= at) videoOutput->consume(start, end); else audioOutput->consume(start, end);
}
if (videoOutput) videoOutput->finish(end);
if (audioOutput) audioOutput->finish(end);
check(av_write_trailer(mux.format)); avio_flush(mux.io);
require(mux.io->error >= 0 && !mux.bytes.empty());
return {id, end - start, std::move(mux.bytes)};
}
};
bool number(std::string_view text, int64_t &value) {
if (text.empty()) return false;
auto result = std::from_chars(text.data(), text.data() + text.size(), value);
return result.ec == std::errc() && result.ptr == text.data() + text.size() && value >= 0;
}
}
struct CastMedia {
Source source;
std::atomic<bool> cancelled{false};
std::mutex lock;
std::condition_variable wake;
int listener = -1;
std::vector<int> clients;
std::array<std::thread, 2> servers;
std::thread producer;
std::string base, token;
double duration = 0;
std::deque<std::shared_ptr<const Segment>> segments;
std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles;
#ifdef CAST_MEDIA_CHECK
double testSeconds = 6;
#endif
bool liveFailed = false;
explicit CastMedia(Source input) : source(std::move(input)) {}
~CastMedia() {
cancel();
if (producer.joinable()) producer.join();
for (auto &worker : servers) if (worker.joinable()) worker.join();
if (listener >= 0) close(listener);
}
void cancel() {
cancelled = true; wake.notify_all();
std::lock_guard guard(lock);
if (listener >= 0) shutdown(listener, SHUT_RDWR);
for (int socket : clients) shutdown(socket, SHUT_RDWR);
}
void bindReceiver(const char *host) {
require(host && std::strlen(host) <= 1024);
addrinfo hints{}; hints.ai_socktype = SOCK_DGRAM; hints.ai_family = AF_INET;
addrinfo *addresses = nullptr;
if (getaddrinfo(host, "8009", &hints, &addresses) != 0) {
hints.ai_family = AF_UNSPEC;
require(getaddrinfo(host, "8009", &hints, &addresses) == 0);
}
std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> owner(addresses, freeaddrinfo);
sockaddr_storage local{}; socklen_t length = 0; int family = 0;
for (auto *address = addresses; address; address = address->ai_next) {
int route = socket(address->ai_family, SOCK_DGRAM, 0);
if (route < 0) continue;
length = sizeof(local);
bool found = connect(route, address->ai_addr, address->ai_addrlen) == 0 && getsockname(route, reinterpret_cast<sockaddr *>(&local), &length) == 0;
close(route);
if (found) { family = address->ai_family; break; }
}
require(family != 0 && !cancelled);
char name[NI_MAXHOST]; require(getnameinfo(reinterpret_cast<sockaddr *>(&local), length, name, sizeof(name), nullptr, 0, NI_NUMERICHOST) == 0);
if (family == AF_INET) reinterpret_cast<sockaddr_in *>(&local)->sin_port = 0;
else reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port = 0;
int server = socket(family, SOCK_STREAM, 0); require(server >= 0);
if (fcntl(server, F_SETFL, O_NONBLOCK) < 0) { close(server); require(false); }
int noSIGPIPE = 1; setsockopt(server, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, sizeof(noSIGPIPE));
if (::bind(server, reinterpret_cast<sockaddr *>(&local), length) != 0 || listen(server, 8) != 0 || getsockname(server, reinterpret_cast<sockaddr *>(&local), &length) != 0) { close(server); require(false); }
unsigned port = ntohs(family == AF_INET ? reinterpret_cast<sockaddr_in *>(&local)->sin_port : reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port);
std::array<unsigned char, 32> random{}; arc4random_buf(random.data(), random.size());
for (auto byte : random) { char hex[3]; std::snprintf(hex, sizeof(hex), "%02x", byte); token += hex; }
std::string hostname = name;
// URLComponents expects escaped IPv6 scope identifiers.
auto scope = hostname.find('%'); if (scope != std::string::npos) hostname.replace(scope, 1, "%25");
base = "http://" + (family == AF_INET6 ? "[" + hostname + "]" : hostname) + ":" + std::to_string(port) + "/" + token + "/";
std::lock_guard guard(lock); listener = server;
if (cancelled) shutdown(listener, SHUT_RDWR);
}
void produce(std::unique_ptr<Pipeline> pipeline) {
try {
double first = std::min(pipeline->video ? pipeline->video->time() : INFINITY, pipeline->audio ? pipeline->audio->time() : INFINITY);
require(std::isfinite(first));
#ifndef CAST_MEDIA_CHECK
auto origin = Clock::now();
#endif
for (int64_t id = 0; !cancelled; ++id) {
auto segment = pipeline->segment(id, first + id * segmentSeconds, first + (id + 1) * segmentSeconds);
{ std::lock_guard guard(lock); segments.push_back(std::make_shared<const Segment>(std::move(segment))); while (segments.size() > windowSegments) segments.pop_front(); }
wake.notify_all();
std::unique_lock guard(lock);
#ifdef CAST_MEDIA_CHECK
wake.wait(guard, [&] { return cancelled || testSeconds >= (id + 1) * segmentSeconds; });
#else
wake.wait_until(guard, origin + std::chrono::milliseconds(int64_t((id + 1) * segmentSeconds * 1000)), [&] { return bool(cancelled); });
#endif
}
} catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); }
}
std::vector<uint8_t> playlist() {
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n";
int64_t first = 0, count = int64_t(std::ceil(duration / segmentSeconds));
std::lock_guard guard(lock);
if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); }
else text += "#EXT-X-PLAYLIST-TYPE:VOD\n";
text += "#EXT-X-MEDIA-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) {
char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : std::min(segmentSeconds, duration - id * segmentSeconds);
std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length);
// Each independently generated segment resets codec/mux state.
if (id > first) text += "#EXT-X-DISCONTINUITY\n";
text += value; text += "segment/" + std::to_string(id) + ".ts\n";
}
if (!source.live) text += "#EXT-X-ENDLIST\n";
return {text.begin(), text.end()};
}
void sendAll(int socket, const uint8_t *bytes, size_t size) {
const auto deadline = Clock::now() + std::chrono::seconds(15);
while (size) {
require(!cancelled && Clock::now() < deadline);
pollfd descriptor{socket, POLLOUT, 0}; require(poll(&descriptor, 1, 1000) >= 0);
if (!(descriptor.revents & POLLOUT)) continue;
ssize_t count = send(socket, bytes, std::min(size, size_t(65536)), 0); require(count > 0);
bytes += count; size -= size_t(count);
}
}
void response(int socket, int status, const char *type, const std::vector<uint8_t> &bytes, bool head, std::string_view range = {}) {
size_t begin = 0, end = bytes.size(); std::string extra;
if (!range.empty() && status == 200) {
int64_t low = 0, high = 0; auto split = range.find('-');
if (!range.starts_with("bytes=") || split == std::string_view::npos || range.find(',') != std::string_view::npos) status = 416;
else if (split == 6) {
if (!number(range.substr(7), high) || high == 0 || bytes.empty()) status = 416;
else begin = bytes.size() - std::min(bytes.size(), size_t(high));
} else if (!number(range.substr(6, split - 6), low) || size_t(low) >= bytes.size()) status = 416;
else {
begin = size_t(low);
if (split + 1 < range.size()) {
if (!number(range.substr(split + 1), high) || high < low) status = 416;
else end = std::min(bytes.size(), size_t(high) + 1);
}
}
if (status != 416) { status = 206; extra = "Content-Range: bytes " + std::to_string(begin) + "-" + std::to_string(end - 1) + "/" + std::to_string(bytes.size()) + "\r\n"; }
else { extra = "Content-Range: bytes */" + std::to_string(bytes.size()) + "\r\n"; begin = end = 0; }
}
std::string header = "HTTP/1.1 " + std::to_string(status) + (status == 200 ? " OK" : status == 206 ? " Partial Content" : " Error") + "\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, HEAD, OPTIONS\r\nAccess-Control-Allow-Headers: Range\r\nAccess-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\nCache-Control: no-store\r\nAccept-Ranges: bytes\r\nContent-Type: " + type + "\r\n" + extra + "Content-Length: " + std::to_string(end - begin) + "\r\n\r\n";
sendAll(socket, reinterpret_cast<const uint8_t *>(header.data()), header.size());
if (!head && end > begin) sendAll(socket, bytes.data() + begin, end - begin);
}
void serve(int socket) {
std::string request; std::array<char, 2048> buffer{};
auto deadline = Clock::now() + std::chrono::seconds(5);
while (request.find("\r\n\r\n") == std::string::npos) {
require(!cancelled && request.size() < 16384 && Clock::now() < deadline);
pollfd descriptor{socket, POLLIN, 0}; require(poll(&descriptor, 1, 500) >= 0);
if (!(descriptor.revents & POLLIN)) continue;
ssize_t size = recv(socket, buffer.data(), std::min(buffer.size(), 16384 - request.size()), 0); require(size > 0); request.append(buffer.data(), size_t(size));
}
auto first = request.find(' '), second = request.find(' ', first == std::string::npos ? 0 : first + 1);
require(first != std::string::npos && second != std::string::npos && second < request.find("\r\n"));
auto method = request.substr(0, first), path = request.substr(first + 1, second - first - 1);
bool head = method == "HEAD";
if (!path.starts_with("/" + token + "/")) { response(socket, 404, "text/plain", {}, head); return; }
if (method == "OPTIONS") { response(socket, 204, "text/plain", {}, true); return; }
if (method != "GET" && !head) { response(socket, 405, "text/plain", {}, true); return; }
std::string range;
size_t cursor = request.find("\r\n") + 2;
while (cursor < request.size()) {
auto next = request.find("\r\n", cursor); if (next == cursor || next == std::string::npos) break;
auto line = request.substr(cursor, next - cursor); auto colon = line.find(':'); require(colon != std::string::npos);
auto name = line.substr(0, colon); for (char &c : name) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
if (name == "range") { require(range.empty()); auto begin = line.find_first_not_of(' ', colon + 1); require(begin != std::string::npos); range = line.substr(begin); }
cursor = next + 2;
}
path.erase(0, token.size() + 2);
std::vector<uint8_t> data; const char *type = "video/mp2t"; int status = 200;
std::shared_ptr<const std::vector<uint8_t>> retained;
if (path == "index.m3u8") { data = playlist(); type = "application/vnd.apple.mpegurl"; }
else if (path.starts_with("subtitle/") && path.ends_with(".vtt")) {
int64_t id = 0;
if (!number(std::string_view(path).substr(9, path.size() - 13), id) || id > 32) status = 404;
else { std::lock_guard guard(lock); auto found = subtitles.find(int(id)); if (found == subtitles.end()) status = 404; else retained = found->second; }
type = "text/vtt; charset=utf-8";
} else if (path.starts_with("segment/") && path.ends_with(".ts")) {
int64_t id = 0;
if (!number(std::string_view(path).substr(8, path.size() - 11), id)) status = 404;
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; });
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 {
Pipeline pipeline(source, cancelled, id * segmentSeconds);
data = pipeline.segment(id, id * segmentSeconds, std::min(duration, (id + 1) * segmentSeconds)).bytes;
}
} else status = 404;
response(socket, status, type, retained ? *retained : data, head, range);
}
void server() {
while (!cancelled) {
pollfd descriptor{listener, POLLIN, 0}; if (poll(&descriptor, 1, 500) <= 0) continue;
if (!(descriptor.revents & POLLIN)) continue;
int socket = accept(listener, nullptr, nullptr); if (socket < 0) continue;
int one = 1; setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
timeval timeout{1, 0}; setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
{ std::lock_guard guard(lock); clients.push_back(socket); if (cancelled) shutdown(socket, SHUT_RDWR); }
try { serve(socket); } catch (...) { try { response(socket, 503, "text/plain", {}, true); } catch (...) {} }
{ std::lock_guard guard(lock); clients.erase(std::remove(clients.begin(), clients.end(), socket), clients.end()); close(socket); }
}
}
};
extern "C" CastMedia *cast_media_create(const CastMediaSource *input, char error[512]) {
try {
require(input && input->video_url && std::strlen(input->video_url) <= 65536);
require(playback::validHeaders(input->video_headers) && playback::validHeaders(input->audio_headers));
require(std::isfinite(input->duration) && input->duration >= 0);
std::string video = input->video_url;
if (input->local) require(!video.empty() && video.front() == '/' && video.find('\0') == std::string::npos);
else require(playback::validNetworkURL(video.c_str()));
if (input->audio_url && *input->audio_url) require(playback::validNetworkURL(input->audio_url));
av_log_set_callback(privateLog);
// Bound demuxer-controlled allocations before probing or packet reads allocate them.
av_max_alloc(128 * 1024 * 1024);
return new CastMedia({video, input->video_headers ? input->video_headers : "", input->audio_url ? input->audio_url : "", input->audio_headers ? input->audio_headers : "", input->local, input->restricted, input->live, input->duration});
} catch (...) { if (error) std::snprintf(error, 512, "Could not prepare cast media (private details redacted)."); return nullptr; }
}
extern "C" bool cast_media_start(CastMedia *media, const char *host, char url[512], double *duration, char error[512]) {
try {
require(media && host && url && duration && !media->cancelled);
auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0);
media->duration = pipeline->duration;
media->bindReceiver(host);
if (media->source.live) {
media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); });
std::unique_lock guard(media->lock);
require(media->wake.wait_for(guard, std::chrono::seconds(50), [&] { return media->segments.size() >= 2 || media->liveFailed || media->cancelled; }));
require(media->segments.size() >= 2 && !media->liveFailed && !media->cancelled);
}
for (auto &worker : media->servers) worker = std::thread([media] { media->server(); });
require(media->base.size() + 11 < 512); std::snprintf(url, 512, "%sindex.m3u8", media->base.c_str()); *duration = media->source.live ? 0 : media->duration;
return true;
} catch (...) { if (media) media->cancel(); if (error) std::snprintf(error, 512, "Could not start cast delivery (private details redacted)."); return false; }
}
extern "C" bool cast_media_subtitle(CastMedia *media, int id, const void *bytes, size_t size, char url[512]) {
try {
require(media && !media->cancelled && id > 0 && id <= 32 && bytes && size > 0 && size <= 4 * 1024 * 1024 && url);
std::lock_guard guard(media->lock);
auto *data = static_cast<const uint8_t *>(bytes); media->subtitles[id] = std::make_shared<const std::vector<uint8_t>>(data, data + size);
require(media->base.size() + 32 < 512); std::snprintf(url, 512, "%ssubtitle/%d.vtt", media->base.c_str(), id); return true;
} catch (...) { return false; }
}
extern "C" void cast_media_cancel(CastMedia *media) { if (media) media->cancel(); }
extern "C" void cast_media_destroy(CastMedia *media) { delete media; }
#ifdef CAST_MEDIA_CHECK
extern "C" bool cast_media_check_advance(CastMedia *media, double seconds) {
if (!media || !media->source.live || !std::isfinite(seconds) || seconds < 6 || seconds > 3600) return false;
std::unique_lock guard(media->lock);
media->testSeconds = seconds;
media->wake.notify_all();
const auto target = int64_t(seconds / segmentSeconds);
media->wake.wait(guard, [&] { return media->cancelled || media->liveFailed ||
(!media->segments.empty() && media->segments.back()->id >= target); });
return !media->cancelled && !media->liveFailed;
}
#endif
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct CastMedia CastMedia;
typedef struct CastMediaSource {
const char *video_url;
const char *video_headers;
const char *audio_url;
const char *audio_headers;
bool local;
bool restricted;
bool live;
double duration;
} CastMediaSource;
// create copies input. start/probe/destroy may block; call off the UI thread.
CastMedia *cast_media_create(const CastMediaSource *source, char error[512]);
bool cast_media_start(CastMedia *media, const char *receiver_host, char url[512], double *duration, char error[512]);
// Copies a validated UTF-8 WebVTT resource, at most 4 MiB/track and 32 tracks.
bool cast_media_subtitle(CastMedia *media, int identifier, const void *bytes, size_t size, char url[512]);
#ifdef CAST_MEDIA_CHECK
// Dedicated check build only: fixed monotonic clock; waits for publication.
bool cast_media_check_advance(CastMedia *media, double seconds);
#endif
// Nonblocking cancellation; destroy subsequently joins all workers and closes sockets.
void cast_media_cancel(CastMedia *media);
void cast_media_destroy(CastMedia *media);
#ifdef __cplusplus
}
#endif
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include <array>
#include <cstring>
#include <string>
#include <string_view>
#include <arpa/inet.h>
namespace playback {
inline bool validNetworkURL(const char *text) {
if (!text) return false;
const std::string_view url(text);
if (url.size() > 65536) return false;
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
if (!start) return false;
for (unsigned char value : url)
if (value <= 32 || value == 127 || value == '\\') return false;
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
auto host = authority;
std::string_view port;
if (host.front() == '[') {
const auto end = host.find(']');
if (end == std::string_view::npos || end == 1) return false;
if (end + 1 < host.size()) {
if (host[end + 1] != ':') return false;
port = host.substr(end + 2);
if (port.empty()) return false;
}
host = host.substr(1, end - 1);
std::array<char, INET6_ADDRSTRLEN> address{};
if (host.size() >= address.size()) return false;
std::memcpy(address.data(), host.data(), host.size());
in6_addr parsed{};
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
} else {
const auto colon = host.find(':');
if (colon != std::string_view::npos) {
port = host.substr(colon + 1);
host = host.substr(0, colon);
if (port.empty()) return false;
}
if (host.empty()) return false;
for (unsigned char value : host)
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
}
unsigned number = 0;
for (unsigned char value : port) {
if (value < '0' || value > '9') return false;
number = number * 10 + value - '0';
if (number > 65535) return false;
}
return port.empty() || number > 0;
}
inline bool validHeaders(const char *text) {
if (!text) return true;
const std::string_view headers(text);
if (headers.size() > 65536) return false;
std::size_t offset = 0;
while (offset < headers.size()) {
const auto end = headers.find("\r\n", offset);
if (end == std::string_view::npos) return false;
const auto line = headers.substr(offset, end - offset);
const auto colon = line.find(':');
if (colon == std::string_view::npos || colon == 0) return false;
for (unsigned char value : line.substr(0, colon))
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') ||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
for (unsigned char value : line.substr(colon + 1))
if (value < 32 || value == 127) return false;
offset = end + 2;
}
return true;
}
inline std::string networkOrigin(const char *text) {
if (!validNetworkURL(text)) return {};
std::string_view url(text);
const auto start = url.starts_with("https://") ? 8u : 7u;
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
for (char &value : authority)
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
else authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
return std::string(url.substr(0, start)) + authority;
}
}
+43 -88
View File
@@ -33,6 +33,7 @@ extern "C" {
#include <arpa/inet.h>
#include <vector>
#include "network_input.hpp"
#include "network_policy.hpp"
namespace {
constexpr int sampleRate = 48000;
@@ -73,7 +74,7 @@ struct Track {
uint64_t timestampOccurrence = 0;
AVStream *stream() const { return format->streams[index]; }
};
enum class Action { Open, Play, Pause, Seek, Step, Close };
enum class Action { Open, Play, Pause, Seek, Step, Close, Remote };
struct Command {
Action action;
std::string path;
@@ -89,6 +90,7 @@ struct Command {
bool live = false;
std::string mediaURL{};
double initialPosition = 0;
PlayerState remoteState{};
};
void copyError(char *destination, const char *message) noexcept {
@@ -100,90 +102,9 @@ int64_t presentationTimestamp(const AVFrame *frame) {
frame->best_effort_timestamp : frame->pts;
}
bool validNetworkURL(const char *text) {
if (!text) return false;
const std::string_view url(text);
if (url.size() > 65536) return false;
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
if (!start) return false;
for (unsigned char value : url)
if (value <= 32 || value == 127 || value == '\\') return false;
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
auto host = authority;
std::string_view port;
if (host.front() == '[') {
const auto end = host.find(']');
if (end == std::string_view::npos || end == 1) return false;
if (end + 1 < host.size()) {
if (host[end + 1] != ':') return false;
port = host.substr(end + 2);
if (port.empty()) return false;
}
host = host.substr(1, end - 1);
std::array<char, INET6_ADDRSTRLEN> address{};
if (host.size() >= address.size()) return false;
std::memcpy(address.data(), host.data(), host.size());
in6_addr parsed{};
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
} else {
const auto colon = host.find(':');
if (colon != std::string_view::npos) {
port = host.substr(colon + 1);
host = host.substr(0, colon);
if (port.empty()) return false;
}
if (host.empty()) return false;
for (unsigned char value : host)
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
}
unsigned number = 0;
for (unsigned char value : port) {
if (value < '0' || value > '9') return false;
number = number * 10 + value - '0';
if (number > 65535) return false;
}
return port.empty() || number > 0;
}
bool validHeaders(const char *text) {
if (!text) return true;
const std::string_view headers(text);
if (headers.size() > 65536) return false;
std::size_t offset = 0;
while (offset < headers.size()) {
const auto end = headers.find("\r\n", offset);
if (end == std::string_view::npos) return false;
const auto line = headers.substr(offset, end - offset);
const auto colon = line.find(':');
if (colon == std::string_view::npos || colon == 0) return false;
for (unsigned char value : line.substr(0, colon))
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') ||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
for (unsigned char value : line.substr(colon + 1))
if (value < 32 || value == 127) return false;
offset = end + 2;
}
return true;
}
std::string networkOrigin(const char *text) {
if (!validNetworkURL(text)) return {};
std::string_view url(text);
const auto start = url.starts_with("https://") ? 8u : 7u;
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
for (char &value : authority)
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
else {
// Canonicalize explicit default ports and leading zeroes.
authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
}
return std::string(url.substr(0, start)) + authority;
}
using playback::validNetworkURL;
using playback::validHeaders;
using playback::networkOrigin;
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
int flags, AVDictionary **options) noexcept {
@@ -275,6 +196,7 @@ struct Player {
bool previewShown = false;
std::array<char, 512> error{};
std::unique_ptr<playback::ProgressReporter> reporter;
bool remoteReporting = false;
std::thread worker;
Player() : worker([this] { run(); }) {}
@@ -436,7 +358,7 @@ struct Player {
}
std::memcpy(state.error, error.data(), error.size());
++state.revision;
if (reporter && reportingInputsHealthy()) reporter->observe(state);
if (reporter && !remoteReporting && reportingInputsHealthy()) reporter->observe(state);
}
bool reportingInputsHealthy() const {
@@ -445,7 +367,7 @@ struct Player {
}
void reportBoundary() {
if (!reporter || !reportingInputsHealthy()) return;
if (!reporter || remoteReporting || !reportingInputsHealthy()) return;
// This is the old worker-owned timeline, even if a new open has already
// replaced the public state with OPENING. Never sample a queued seek target.
PlayerState snapshot{};
@@ -624,6 +546,7 @@ struct Player {
void open(const Command &command) {
reportBoundary();
remoteReporting = false;
if (reporter) reporter->begin(command.mediaURL, command.live);
release();
network = command.network;
@@ -977,7 +900,9 @@ struct Player {
}
void execute(const Command &command) {
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
// Receiver facts precede the next open/close even when it supersedes decoder I/O.
if (command.action == Action::Remote ? command.epoch != activeEpoch :
command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
activeEpoch = command.epoch;
if (reporter && (command.action == Action::Seek || command.action == Action::Step)) {
// Local seeks can finish without a pending publication. Preserve
@@ -991,9 +916,18 @@ struct Player {
case Action::Pause: pause(); break;
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
case Action::Step: step(command.direction); break;
case Action::Remote:
if (!remoteReporting) {
reportBoundary();
release();
}
remoteReporting = true;
if (reporter) reporter->observe(command.remoteState);
return;
case Action::Close:
reportBoundary();
if (reporter) reporter->closeMedia();
remoteReporting = false;
release();
break;
}
@@ -1418,6 +1352,15 @@ struct Player {
}
}
try {
if (reporter) {
std::lock_guard<std::mutex> lock(mutex);
for (const auto &command : commands) {
if (command.action == Action::Remote && command.epoch == activeEpoch) {
reporter->observe(command.remoteState);
remoteReporting = true;
}
}
}
reportBoundary();
if (reporter) reporter->closeMedia();
release();
@@ -1520,6 +1463,18 @@ bool player_step_frame(Player *player, int direction) {
bool player_close(Player *player) {
return submit(player, [=] { return player->enqueue({Action::Close, {}}); });
}
bool player_report_remote_state(Player *player, const PlayerState *state) {
if (!state || !std::isfinite(state->position) || state->position < 0 ||
!std::isfinite(state->duration) || state->duration < 0 ||
(state->status != PLAYER_PAUSED && state->status != PLAYER_PLAYING &&
state->status != PLAYER_ENDED)) return false;
const PlayerState snapshot = *state;
return submit(player, [=] {
Command command{Action::Remote, {}};
command.remoteState = snapshot;
return player->enqueue(std::move(command));
});
}
bool player_set_volume(Player *player, float normalized0to1) {
if (!std::isfinite(normalized0to1) || normalized0to1 < 0 || normalized0to1 > 1) return false;
return submit(player, [=] {
+3
View File
@@ -71,6 +71,9 @@ bool player_seek(Player *player, double seconds);
// Pauses playback and selects the adjacent decoded presentation frame.
bool player_step_frame(Player *player, int direction);
bool player_close(Player *player);
// Receiver-confirmed Cast facts replace decoder facts in progress reporting until
// the next open/close. The local decoder must be paused before submitting them.
bool player_report_remote_state(Player *player, const PlayerState *state);
// Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes.
// Rejects null handles and non-finite/out-of-range values without changing gain.
bool player_set_volume(Player *player, float normalized0to1);
+1
View File
@@ -1,6 +1,7 @@
#ifndef PERSONAL_PLAYER_MACOS_H
#define PERSONAL_PLAYER_MACOS_H
#include "player.h"
#include "cast_media.h"
#ifdef __cplusplus
extern "C" {
#endif
+1 -1
View File
@@ -1,5 +1,5 @@
- [x] add miruro streaming support
- [x] investigate and fix audio when streaming from miruro - audio seems to crackle (not for youtube i think; only for miruros)
- [ ] add chrome cast support — implemented; verify playback, captions and A/V sync on a physical Cast device
- [ ] in the "choose what to play" screen, we should change the youtube button 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)
- [ ] add chrome cast support
+2 -1
View File
@@ -9,7 +9,7 @@ Tubular integration is still required. Terebi's source/progress contracts below
## Ownership
- **Tubular:** discovery, episode/provider/language selection, fresh source resolution, source-document creation, session cleanup and watched-state decisions.
- **Terebi:** native playback, request-header propagation, subtitle loading/selection/rendering, stable-identity bookmarks and acknowledged progress reporting.
- **Terebi:** native playback, Cast discovery/control and LAN delivery, request-header propagation, subtitle loading/selection/rendering, stable-identity bookmarks and acknowledged progress reporting.
No Miruro scraping, `strm.cx` embedding, cookies, impersonation or protection-bypass machinery was added to Terebi.
@@ -85,6 +85,7 @@ Subtitle headers **do not inherit** stream headers. The verified Sun smoke expli
- Cues follow acknowledged player position, including seeking backwards. Zero-duration cues are permanently inactive rather than invalidating the track. Rendering is plain text; WebVTT CSS, positioning and typographic styling are not applied.
- Subtitle HTTP requests use their own headers. Same-origin redirects retain those headers; cross-origin redirects are rejected. Responses are capped at 4 MiB, with 15-second request and 20-second resource timeouts.
- Media errors remain visible with the operation/native error. Subtitle failures appear separately beside the captions menu, including when other controls are hidden. FFmpeg diagnostics redact private details; source/subtitle errors do not interpolate URLs or header values.
- Casting uses the same episode identity and progress schema; receiver-confirmed positions replace paused local-decoder facts. The Mac relays media/VTT without exposing source URLs or credentials to the receiver. See [Chromecast controls, transport limits and verification](../README.md#chromecast); physical receiver playback/captions remain unverified.
YouTube's existing launch, version-1 progress and transport behavior remain supported.