From bb777b0ecd5ff48661da74606f683d016085d7d6 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 13 Oct 2026 17:31:37 +0200 Subject: [PATCH] feat: add Chromecast playback support --- Checks/cast-media.cpp | 325 +++++++++++ Checks/cast-playback.swift | 69 +++ Checks/cast-session.swift | 636 ++++++++++++++++++++++ Checks/network-playback.mm | 12 + Makefile | 30 +- README.md | 28 +- Resources/Info.plist | 2 + Scripts/dependencies.py | 1 + Sources/App/CastControls.swift | 48 ++ Sources/App/CastMediaServer.swift | 145 +++++ Sources/App/CastSession.swift | 806 ++++++++++++++++++++++++++++ Sources/App/ExternalSubtitles.swift | 12 +- Sources/App/PlayerActions.swift | 2 + Sources/App/TerebiApp.swift | 276 +++++++++- Sources/Core/cast_media.cpp | 758 ++++++++++++++++++++++++++ Sources/Core/cast_media.h | 34 ++ Sources/Core/network_policy.hpp | 88 +++ Sources/Core/player.cpp | 131 ++--- Sources/Core/player.h | 3 + Sources/Core/player_macos.h | 1 + TODO.md | 4 +- docs/miruro-playback-handoff.md | 3 +- 22 files changed, 3299 insertions(+), 115 deletions(-) create mode 100644 Checks/cast-media.cpp create mode 100644 Checks/cast-playback.swift create mode 100644 Checks/cast-session.swift create mode 100644 Sources/App/CastControls.swift create mode 100644 Sources/App/CastMediaServer.swift create mode 100644 Sources/App/CastSession.swift create mode 100644 Sources/Core/cast_media.cpp create mode 100644 Sources/Core/cast_media.h create mode 100644 Sources/Core/network_policy.hpp diff --git a/Checks/cast-media.cpp b/Checks/cast-media.cpp new file mode 100644 index 0000000..0aa933d --- /dev/null +++ b/Checks/cast-media.cpp @@ -0,0 +1,325 @@ +#include "cast_media.h" +extern "C" { +#include +#include +#include +#include +} +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void ok(int result) { assert(result >= 0); } +static std::vector readFile(const std::string &path) { + std::ifstream file(path, std::ios::binary); assert(file.good()); + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; +} +static void writeFile(const std::string &path, const std::vector &bytes) { + std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast(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(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 wav() { + std::vector 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 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(&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 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 stopped{false}; + std::atomic foreignRequests{0}, videoRequests{0}, audioRequests{0}; + std::map> resources; + std::string foreign; + explicit Upstream(std::vector video, std::vector 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(&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(&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 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 codecs; std::map 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 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(&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"; +} diff --git a/Checks/cast-playback.swift b/Checks/cast-playback.swift new file mode 100644 index 0000000..0311a7e --- /dev/null +++ b/Checks/cast-playback.swift @@ -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") + } +} diff --git a/Checks/cast-session.swift b/Checks/cast-session.swift new file mode 100644 index 0000000..c8ea092 --- /dev/null +++ b/Checks/cast-session.swift @@ -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.. + } + 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") + } + } +} diff --git a/Checks/network-playback.mm b/Checks/network-playback.mm index e933c3c..c599037 100644 --- a/Checks/network-playback.mm +++ b/Checks/network-playback.mm @@ -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); diff --git a/Makefile b/Makefile index de3ebe4..0d7691e 100644 --- a/Makefile +++ b/Makefile @@ -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 $@ diff --git a/README.md b/README.md index 204548e..d5e0a8c 100644 --- a/README.md +++ b/README.md @@ -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/`; 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/`; 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. diff --git a/Resources/Info.plist b/Resources/Info.plist index 52e2074..4962356 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -20,5 +20,7 @@ NSHighResolutionCapable NSPrincipalClassNSApplication + NSLocalNetworkUsageDescriptionDiscover Cast devices and stream your selected media to a TV on your local network. + NSBonjourServices_googlecast._tcp diff --git a/Scripts/dependencies.py b/Scripts/dependencies.py index c87e427..965ea88 100755 --- a/Scripts/dependencies.py +++ b/Scripts/dependencies.py @@ -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"] diff --git a/Sources/App/CastControls.swift b/Sources/App/CastControls.swift new file mode 100644 index 0000000..d3bc71d --- /dev/null +++ b/Sources/App/CastControls.swift @@ -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") + } +} diff --git a/Sources/App/CastMediaServer.swift b/Sources/App/CastMediaServer.swift new file mode 100644 index 0000000..9468143 --- /dev/null +++ b/Sources/App/CastMediaServer.swift @@ -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? + + 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() + } +} diff --git a/Sources/App/CastSession.swift b/Sources/App/CastSession.swift new file mode 100644 index 0000000..17bfc6b --- /dev/null +++ b/Sources/App/CastSession.swift @@ -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.. 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 + } + + 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? + private var readyDeadline: Task? + private var heartbeatTask: Task? + 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 = [] + 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? + 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) 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) } + } +} diff --git a/Sources/App/ExternalSubtitles.swift b/Sources/App/ExternalSubtitles.swift index 5b2de45..57ad1cc 100644 --- a/Sources/App/ExternalSubtitles.swift +++ b/Sources/App/ExternalSubtitles.swift @@ -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 { diff --git a/Sources/App/PlayerActions.swift b/Sources/App/PlayerActions.swift index 471351e..4b7c5d5 100644 --- a/Sources/App/PlayerActions.swift +++ b/Sources/App/PlayerActions.swift @@ -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)) diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 4e05444..5c36e48 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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? + 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? { 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,7 +1012,13 @@ final class PlayerModel: ObservableObject { private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) { guard let handle else { return } - transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress) + 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() { @@ -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)) diff --git a/Sources/Core/cast_media.cpp b/Sources/Core/cast_media.cpp new file mode 100644 index 0000000..84b9e1e --- /dev/null +++ b/Sources/Core/cast_media.cpp @@ -0,0 +1,758 @@ +#include "cast_media.h" +#include "network_policy.hpp" +extern "C" { +#include +#include +#include +#include +#include +#include +} +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +using Packet = std::unique_ptr; +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(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 &cancel; + std::string origin, headers; + std::atomic deadline{0}; + int64_t operationDeadline = std::numeric_limits::max(); + bool network, restricted, live; + Input(std::atomic &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((Clock::now() + std::chrono::seconds(15)).time_since_epoch()).count(); + deadline = std::min(ioDeadline, operationDeadline); + } + static int interrupt(void *opaque) { + auto &in = *static_cast(opaque); + return in.cancel || std::chrono::duration_cast(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(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; + AVStream *stream = nullptr; + AVCodecContext *decoder = nullptr; + Packet pending; + Frame decoded; + bool eof = false, draining = false, remux = false; + double nextTime = 0; + Track(std::unique_ptr 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 bytes; }; +struct Mux { + AVFormatContext *format = nullptr; + AVIOContext *io = nullptr; + std::vector bytes; + Mux() { + check(avformat_alloc_output_context2(&format, nullptr, "mpegts", nullptr)); + auto *buffer = static_cast(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(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(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(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(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 video, audio; + std::unique_ptr videoOutput, audioOutput; + bool live; + std::atomic &cancel; + double duration = 0; + Pipeline(const Source &source, std::atomic &stop, double start) : live(source.live), cancel(stop) { + auto primary = std::make_unique(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(std::move(primary), AVMEDIA_TYPE_VIDEO, start, source.live); + if (!source.audio.empty()) { + audio = std::make_unique(std::make_unique(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(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live); + audio = std::make_unique(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(*video, mux, false); + } + if (audio) { + if (audioOutput) audioOutput->beginSegment(mux); + else audioOutput = std::make_unique(*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(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 cancelled{false}; + std::mutex lock; + std::condition_variable wake; + int listener = -1; + std::vector clients; + std::array servers; + std::thread producer; + std::string base, token; + double duration = 0; + std::deque> segments; + std::map>> 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 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(&local), &length) == 0; + close(route); + if (found) { family = address->ai_family; break; } + } + require(family != 0 && !cancelled); + char name[NI_MAXHOST]; require(getnameinfo(reinterpret_cast(&local), length, name, sizeof(name), nullptr, 0, NI_NUMERICHOST) == 0); + if (family == AF_INET) reinterpret_cast(&local)->sin_port = 0; + else reinterpret_cast(&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(&local), length) != 0 || listen(server, 8) != 0 || getsockname(server, reinterpret_cast(&local), &length) != 0) { close(server); require(false); } + unsigned port = ntohs(family == AF_INET ? reinterpret_cast(&local)->sin_port : reinterpret_cast(&local)->sin6_port); + std::array 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) { + 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(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 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 &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(header.data()), header.size()); + if (!head && end > begin) sendAll(socket, bytes.data() + begin, end - begin); + } + void serve(int socket) { + std::string request; std::array 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 data; const char *type = "video/mp2t"; int status = 200; + std::shared_ptr> 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>(*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(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(bytes); media->subtitles[id] = std::make_shared>(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 diff --git a/Sources/Core/cast_media.h b/Sources/Core/cast_media.h new file mode 100644 index 0000000..276fae7 --- /dev/null +++ b/Sources/Core/cast_media.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#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 diff --git a/Sources/Core/network_policy.hpp b/Sources/Core/network_policy.hpp new file mode 100644 index 0000000..8b80143 --- /dev/null +++ b/Sources/Core/network_policy.hpp @@ -0,0 +1,88 @@ +#pragma once +#include +#include +#include +#include +#include + +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 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; +} +} diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index c56ace1..f3c3337 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -33,6 +33,7 @@ extern "C" { #include #include #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 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 error{}; std::unique_ptr 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 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, [=] { diff --git a/Sources/Core/player.h b/Sources/Core/player.h index 7855423..b4a7c0c 100644 --- a/Sources/Core/player.h +++ b/Sources/Core/player.h @@ -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); diff --git a/Sources/Core/player_macos.h b/Sources/Core/player_macos.h index bc42de0..8133d2f 100644 --- a/Sources/Core/player_macos.h +++ b/Sources/Core/player_macos.h @@ -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 diff --git a/TODO.md b/TODO.md index d5b2765..b737761 100644 --- a/TODO.md +++ b/TODO.md @@ -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 \ No newline at end of file + - 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) \ No newline at end of file diff --git a/docs/miruro-playback-handoff.md b/docs/miruro-playback-handoff.md index 59c7009..ff00eb4 100644 --- a/docs/miruro-playback-handoff.md +++ b/docs/miruro-playback-handoff.md @@ -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.