fix: separate Cast launch readiness, preserve volume intent and reuse seek inputs

This commit is contained in:
2026-10-13 21:40:19 +02:00
parent 697158aa02
commit b9e63cd760
8 changed files with 365 additions and 55 deletions
+75 -3
View File
@@ -25,6 +25,10 @@ extern "C" {
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
#include <poll.h> #include <poll.h>
#include <spawn.h>
#include <sys/wait.h>
extern char **environ;
static void ok(int result) { assert(result >= 0); } static void ok(int result) { assert(result >= 0); }
static std::vector<uint8_t> readFile(const std::string &path) { static std::vector<uint8_t> readFile(const std::string &path) {
@@ -99,6 +103,20 @@ static void fixture(const std::string &path) {
av_bsf_free(&aspect); av_bsf_free(&aspect);
avio_closep(&format->pb); avformat_free_context(format); avcodec_free_context(&video); avcodec_free_context(&audio); avio_closep(&format->pb); avformat_free_context(format); avcodec_free_context(&video); avcodec_free_context(&audio);
} }
// Indexed split MP4 is the resolved YouTube VOD shape. As with check-fixtures,
// the check requires host ffmpeg for MP4 muxing; production uses only bundled libraries.
static void splitFixture(const std::string &source, const std::string &path, AVMediaType type) {
const char *arguments[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", source.c_str(), "-map", type == AVMEDIA_TYPE_VIDEO ? "0:v:0" : "0:a:0",
"-c", "copy", "-movflags", "+faststart", path.c_str(), nullptr};
pid_t child = 0;
int result = posix_spawnp(&child, arguments[0], nullptr, nullptr,
const_cast<char *const *>(arguments), environ);
if (result != 0) std::cerr << "cast-media split fixture requires host ffmpeg (same as make check-fixtures)\n";
assert(result == 0);
int status = 0; assert(waitpid(child, &status, 0) == child);
assert(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
static std::vector<uint8_t> wav() { static std::vector<uint8_t> wav() {
std::vector<uint8_t> bytes(44 + 90 * 48000 * 2); std::vector<uint8_t> bytes(44 + 90 * 48000 * 2);
auto put = [&](size_t at, uint32_t value, int length) { for (int i = 0; i < length; ++i) bytes[at + i] = uint8_t(value >> (8 * i)); }; 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)); };
@@ -128,6 +146,7 @@ static HTTP request(const std::string &url, const std::string &method = "GET", c
struct Upstream { struct Upstream {
int listener; uint16_t port; std::thread threads[4]; std::atomic<bool> stopped{false}; int listener; uint16_t port; std::thread threads[4]; std::atomic<bool> stopped{false};
std::atomic<int> foreignRequests{0}, videoRequests{0}, audioRequests{0}; std::atomic<int> foreignRequests{0}, videoRequests{0}, audioRequests{0};
std::atomic<int> videoStarts{0}, audioStarts{0};
std::map<std::string, std::vector<uint8_t>> resources; std::map<std::string, std::vector<uint8_t>> resources;
std::string foreign; std::string foreign;
explicit Upstream(std::vector<uint8_t> video, std::vector<uint8_t> audio) { explicit Upstream(std::vector<uint8_t> video, std::vector<uint8_t> audio) {
@@ -158,7 +177,7 @@ struct Upstream {
(path == "/public-cross.m3u8" ? "/public.ts" : "/source.ts") + "\n#EXT-X-ENDLIST\n"; (path == "/public-cross.m3u8" ? "/public.ts" : "/source.ts") + "\n#EXT-X-ENDLIST\n";
data.assign(playlist.begin(), playlist.end()); data.assign(playlist.begin(), playlist.end());
} else if (auto resource = resources.find(path); resource != resources.end()) { } else if (auto resource = resources.find(path); resource != resources.end()) {
bool audio = path == "/audio.wav"; bool audio = path == "/audio.wav" || path == "/audio.m4a";
bool publicMedia = path == "/public.ts"; bool publicMedia = path == "/public.ts";
bool authorized = publicMedia || request.find(audio ? "X-Audio: audio-secret" : "X-Video: video-secret") != std::string::npos; 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; bool wrong = request.find(publicMedia ? "secret" : (audio ? "video-secret" : "audio-secret")) != std::string::npos;
@@ -173,6 +192,9 @@ struct Upstream {
if (low >= high) { status = 416; low = high = 0; } 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"; } else { status = 206; extra += "Content-Range: bytes " + std::to_string(low) + "-" + std::to_string(high - 1) + "/" + std::to_string(data.size()) + "\r\n"; }
} }
if ((status == 200 || status == 206) && low == 0 && resources.contains(path)) {
if (path == "/audio.wav" || path == "/audio.m4a") ++audioStarts; else ++videoStarts;
}
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"; 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); 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); } 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); }
@@ -244,15 +266,65 @@ static CastMedia *start(CastMediaSource source, std::string &url, double &durati
char error[512]{}, bytes[512]{}; auto *media = cast_media_create(&source, error); assert(media); 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; assert(cast_media_start(media, "127.0.0.1", bytes, &duration, error)); url = bytes; return media;
} }
static void seekWork(Upstream &upstream, const std::string &segment) {
std::string video = upstream.url("/video.mp4"), audio = upstream.url("/audio.m4a"), url;
double duration = 0;
CastMediaSource source{video.c_str(), "X-Video: video-secret\r\n", audio.c_str(), "X-Audio: audio-secret\r\n", false, false, false, 90};
auto *media = start(source, url, duration);
std::string base = url.substr(0, url.rfind('/') + 1);
int videoStarts = upstream.videoStarts, audioStarts = upstream.audioStarts;
auto distant = request(base + "segment/10.ts");
playable(distant, segment, true, 60);
int videoRequests = upstream.videoRequests, audioRequests = upstream.audioRequests;
auto repeated = request(base + "segment/10.ts");
std::cout << "seek-work repeated upstream requests: video=" << int(upstream.videoRequests) - videoRequests
<< " audio=" << int(upstream.audioRequests) - audioRequests << std::endl;
bool repeatedHasNoIO = upstream.videoRequests == videoRequests && upstream.audioRequests == audioRequests;
bool repeatedIdentical = repeated.status == 200 && repeated.bytes == distant.bytes;
auto adjacent = request(base + "segment/11.ts");
playable(adjacent, segment, true, 66);
auto before = audioSpan(distant, segment), after = audioSpan(adjacent, segment);
assert(std::abs(after.first - before.second) < 1.0 / 48000);
playable(request(base + "segment/2.ts"), segment, true, 12);
playable(request(base + "segment/12.ts"), segment, true, 72);
std::cout << "seek-work new origin-zero requests across distant/adjacent seeks: video="
<< int(upstream.videoStarts) - videoStarts << " audio=" << int(upstream.audioStarts) - audioStarts << std::endl;
bool seeksDoNotReprobe = upstream.videoStarts == videoStarts && upstream.audioStarts == audioStarts;
// A previous random segment remains independently replayable without upstream IO.
videoRequests = upstream.videoRequests; audioRequests = upstream.audioRequests;
bool replayIdentical = request(base + "segment/10.ts").bytes == distant.bytes;
auto head = request(base + "segment/10.ts", "HEAD");
auto range = request(base + "segment/10.ts", "GET", "Range: bytes=0-187\r\n");
assert(head.status == 200 && head.bytes.empty());
assert(range.status == 206 && range.bytes == std::vector<uint8_t>(distant.bytes.begin(), distant.bytes.begin() + 188));
bool replayHasNoIO = upstream.videoRequests == videoRequests && upstream.audioRequests == audioRequests;
cast_media_destroy(media);
assert(seeksDoNotReprobe);
assert(repeatedHasNoIO && repeatedIdentical && replayHasNoIO && replayIdentical);
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
bool liveCheck = false; bool liveCheck = false, seekCheck = false;
for (int argument = 1; argument < argc; ++argument) if (std::string(argv[argument]) == "--live") liveCheck = true; for (int argument = 1; argument < argc; ++argument) {
if (std::string(argv[argument]) == "--live") liveCheck = true;
if (std::string(argv[argument]) == "--seek-work") seekCheck = true;
}
char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp); char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp);
const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string(); const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string();
fixture(video); writeFile(audio, wav()); fixture(video); writeFile(audio, wav());
const auto splitVideo = (directory / "video.mp4").string(), splitAudio = (directory / "audio.m4a").string();
splitFixture(video, splitVideo, AVMEDIA_TYPE_VIDEO);
splitFixture(video, splitAudio, AVMEDIA_TYPE_AUDIO);
auto videoBytes = readFile(video), audioBytes = readFile(audio); auto videoBytes = readFile(video), audioBytes = readFile(audio);
Upstream upstream(videoBytes, audioBytes), foreign(videoBytes, audioBytes); Upstream upstream(videoBytes, audioBytes), foreign(videoBytes, audioBytes);
upstream.resources["/video.mp4"] = readFile(splitVideo);
upstream.resources["/audio.m4a"] = readFile(splitAudio);
upstream.foreign = foreign.url(""); upstream.start(); foreign.start(); upstream.foreign = foreign.url(""); upstream.start(); foreign.start();
seekWork(upstream, segment);
if (seekCheck) {
std::filesystem::remove_all(directory);
std::cout << "cast-media: split VOD seek work passed\n";
return 0;
}
std::string url; double duration = 0; std::string url; double duration = 0;
CastMediaSource local{video.c_str(), "", nullptr, nullptr, true, false, false, 0}; CastMediaSource local{video.c_str(), "", nullptr, nullptr, true, false, false, 0};
auto *media = start(local, url, duration); assert(duration >= 89 && duration <= 92); auto *media = start(local, url, duration); assert(duration >= 89 && duration <= 92);
+5
View File
@@ -20,6 +20,11 @@ struct CastPlaybackCheck {
static func main() async throws { static func main() async throws {
_ = NSApplication.shared _ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited) NSApp.setActivationPolicy(.prohibited)
require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle")
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" {
print("PASS: native volume slider tracking preserves user input and idle receiver feedback")
return
}
let source = URL(fileURLWithPath: CommandLine.arguments[1]) let source = URL(fileURLWithPath: CommandLine.arguments[1])
let port = UInt16(CommandLine.arguments[2])! let port = UInt16(CommandLine.arguments[2])!
let suite = "terebi.cast-playback-check." + UUID().uuidString let suite = "terebi.cast-playback-check." + UUID().uuidString
+152 -7
View File
@@ -86,6 +86,7 @@ private final class Receiver {
var dropCommands = false var dropCommands = false
var rejectCommand = false var rejectCommand = false
var holdSeek = false var holdSeek = false
var holdVolume = false
var ignoreHeartbeat = false var ignoreHeartbeat = false
var verifyRelay = false var verifyRelay = false
var relayVerified = false var relayVerified = false
@@ -164,10 +165,17 @@ private final class Receiver {
} else { socket?.send(content: frame, completion: .contentProcessed { _ in }) } } else { socket?.send(content: frame, completion: .contentProcessed { _ in }) }
} }
private func receiverStatus(_ requestID: Int) throws { func receiverStatus(_ requestID: Int? = nil, appID: String? = "CC1AD845",
try send(["type": "RECEIVER_STATUS", "requestId": requestID, level: Double? = nil, source: String? = nil) throws {
"status": ["applications": [["appId": "CC1AD845", "sessionId": "app-session", "transportId": transport]], let apps: [[String: Any]] = appID.map { [["appId": $0, "sessionId": "app-session", "transportId": transport]] } ?? []
"volume": ["level": volume, "muted": muted]]], namespace: CastWire.receiver, fragmented: true) var json: [String: Any] = ["type": "RECEIVER_STATUS",
"status": ["applications": apps, "volume": ["level": level ?? volume, "muted": muted]]]
if let requestID { json["requestId"] = requestID }
try send(json, namespace: CastWire.receiver, source: source, fragmented: true)
}
func receiverPing() throws {
try send(["type": "PING"], namespace: CastWire.heartbeat)
} }
func status(requestID: Int = 0, session override: Int? = nil, content: String? = nil, func status(requestID: Int = 0, session override: Int? = nil, content: String? = nil,
@@ -241,9 +249,13 @@ private final class Receiver {
try status(requestID: requestID) try status(requestID: requestID)
case "SET_VOLUME": case "SET_VOLUME":
require(message.namespace == CastWire.receiver, "Volume controls device, not stream") require(message.namespace == CastWire.receiver, "Volume controls device, not stream")
if rejectCommand {
try send(["type": "INVALID_REQUEST", "requestId": requestID], namespace: CastWire.receiver)
return
}
volume = (json["volume"] as? [String: Any])?["level"] as? Double ?? volume volume = (json["volume"] as? [String: Any])?["level"] as? Double ?? volume
muted = (json["volume"] as? [String: Any])?["muted"] as? Bool ?? muted muted = (json["volume"] as? [String: Any])?["muted"] as? Bool ?? muted
try receiverStatus(requestID) if !holdVolume { try receiverStatus(requestID) }
case "STOP": case "STOP":
require(message.namespace == CastWire.media, "Disconnect does not stop another sender's application") require(message.namespace == CastWire.media, "Disconnect does not stop another sender's application")
require(json["mediaSessionId"] as? Int == session, "Disconnect stops only owned media") require(json["mediaSessionId"] as? Int == session, "Disconnect stops only owned media")
@@ -437,6 +449,127 @@ struct CastSessionCheck {
require(disconnected && errors.isEmpty && peer.failure == nil, "Safe disconnect without spurious cancellation errors") require(disconnected && errors.isEmpty && peer.failure == nil, "Safe disconnect without spurious cancellation errors")
} }
static func launchRegressions(identity: SecIdentity) async throws {
for mode in ["cold", "asynchronous", "intermediate"] {
let peer = try Receiver(identity: identity)
peer.dropLaunch = true
try await peer.start()
let clock = CastCheckClock()
let session = CastSession(sleep: { try await clock.sleep($0) })
var outcome: String?
let task = Task {
do { try await session.connect(to: peer.device); outcome = "connected" }
catch { outcome = error.localizedDescription }
}
try await until("LAUNCH arrives before delayed receiver status") {
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.seconds(5))
}
let launchID = peer.commands.first { $0.0 == "LAUNCH" }!.1["requestId"] as! Int
if mode == "cold" {
clock.advance(.seconds(9))
try await until("Cold launch advances past ordinary request deadline") {
outcome != nil || peer.commands.contains { $0.0 == "PING" } && clock.hasDelay(.seconds(5))
}
require(outcome == nil, "Cold receiver launch survives the ordinary eight-second control deadline")
}
let pongCount = peer.commands.filter { $0.0 == "PONG" }.count
try peer.receiverStatus(appID: "unrelated-app")
try peer.receiverStatus(source: "unrelated-transport")
if mode == "intermediate" { try peer.receiverStatus(launchID, appID: nil) }
try peer.receiverPing()
try await until("Ordered receiver barrier after unrelated and intermediate launch statuses") {
outcome != nil || peer.commands.filter { $0.0 == "PONG" }.count == pongCount + 1
}
require(outcome == nil, "Launch waits for Default Media Receiver rather than adopting another app or rejecting intermediate status")
if mode == "cold" {
clock.advance(.seconds(11))
try await until("Cold receiver remains pending beyond the TLS connection deadline") {
outcome != nil || peer.commands.filter { $0.0 == "PING" }.count >= 2 && clock.hasDelay(.seconds(5))
}
require(outcome == nil, "Successful TLS connection does not impose a twelve-second receiver launch deadline")
}
try peer.receiverStatus() // Real receivers can broadcast readiness without the LAUNCH request ID.
try await until("Asynchronous receiver launch status completes connection") { outcome != nil }
require(outcome == "connected", "Default Media Receiver readiness completes launch without the original acknowledgement")
await task.value
try peer.receiverStatus(launchID) // The original acknowledgement can arrive after readiness.
try await session.load(media(), title: "Delayed launch", position: 0, playing: false, subtitle: nil)
require(peer.failure == nil, "Late launch acknowledgement does not disturb acquired receiver ownership")
session.disconnect(stopReceiver: false)
try await until("Delayed launch cancels all clock handles") { clock.pendingCount == 0 }
peer.stop()
}
}
static func volumeRegressions(identity: SecIdentity) async throws {
let peer = try Receiver(identity: identity)
peer.holdVolume = 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] = []
session.onStatus = { statuses.append($0) }
session.onError = { errors.append($0) }
try await session.connect(to: peer.device)
try await session.load(media(), title: "Volume feedback", position: 0, playing: false, subtitle: nil)
func volumeID(_ index: Int) -> Int {
peer.commands.filter { $0.0 == "SET_VOLUME" }[index].1["requestId"] as! Int
}
func barrier(_ message: String) async throws {
let pongs = peer.commands.filter { $0.0 == "PONG" }.count
try peer.status()
try peer.receiverPing()
try await until(message) { peer.commands.filter { $0.0 == "PONG" }.count == pongs + 1 }
}
require(session.setVolume(0.2) && session.setVolume(0.8), "Rapid slider commands accepted")
try await until("Both slider commands reach receiver") { peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 2 }
let beforeBurst = statuses.count
try peer.receiverStatus(volumeID(0), level: 0.2)
let launchID = peer.commands.first { $0.0 == "LAUNCH" }!.1["requestId"] as! Int
try peer.receiverStatus(launchID, level: 0.1)
try peer.receiverStatus(level: 0.8)
try peer.receiverStatus(level: 0.45)
try await barrier("Media status provides barrier after stale volume feedback")
require(statuses.last?.volume == 0.8 && statuses.dropFirst(beforeBurst).allSatisfy { $0.volume == 0.8 },
"Older command and unsolicited receiver feedback cannot pull the slider away from latest user intent")
try peer.receiverStatus(volumeID(1), level: 0.79)
try await barrier("Latest slider acknowledgement supplies receiver-authoritative volume")
require(statuses.last?.volume == 0.79, "Latest acknowledgement restores the receiver's actual quantized volume")
try peer.receiverStatus(volumeID(0), level: 0.2)
try peer.receiverStatus(launchID, level: 0.1)
try await barrier("Late duplicate old volume acknowledgement is consumed")
require(statuses.last?.volume == 0.79, "Old acknowledged request cannot roll volume backward after latest command settles")
try peer.receiverStatus(level: 0.4)
try await barrier("External TV volume arrives while idle")
require(statuses.last?.volume == 0.4, "External TV changes remain authoritative when no latest slider intent is pending")
require(session.setVolume(0.6) && session.setVolume(0.9), "Second overlapping slider burst accepted")
try await until("Second slider burst reaches receiver") { peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 4 }
try peer.receiverStatus(volumeID(3), level: 0.88)
try peer.receiverStatus(volumeID(2), level: 0.6)
try peer.receiverStatus(level: 0.5)
try await barrier("Reverse acknowledgement order and external change processed")
require(statuses.last?.volume == 0.5, "Settled latest intent allows external updates even after an older overlapping request")
require(session.setVolume(0.7), "Unanswered volume command accepted")
try await until("Volume timeout waits on controlled clock") {
peer.commands.filter { $0.0 == "SET_VOLUME" }.count == 5 && clock.hasDelay(.seconds(8))
}
clock.advance(.seconds(8))
try await until("Volume timeout releases latest user intent") { !errors.isEmpty }
require(statuses.last?.volume == 0.5, "Timed-out volume intent restores the last authoritative receiver value")
try peer.receiverStatus(level: 0.7)
try await barrier("External volume becomes authoritative after timeout")
require(statuses.last?.volume == 0.7, "Unsolicited TV volume resumes after bounded command failure")
peer.rejectCommand = true
let beforeRejection = errors.count
require(session.setVolume(0.4), "Rejected volume command is sent")
try await until("Explicit volume rejection releases latest intent") { errors.count > beforeRejection }
require(statuses.last?.volume == 0.7, "Rejected slider intent restores authoritative receiver volume")
session.disconnect(stopReceiver: false)
try await until("Volume regression cancels all clock handles") { clock.pendingCount == 0 }
}
static func connectionDeadline() async throws { static func connectionDeadline() async throws {
let listener = try NWListener(using: .tcp, on: .any) let listener = try NWListener(using: .tcp, on: .any)
@MainActor final class ListenerState { @MainActor final class ListenerState {
@@ -494,9 +627,9 @@ struct CastSessionCheck {
} else if mode == "launch-timeout" { } else if mode == "launch-timeout" {
let task = Task { try await session.connect(to: peer.device) } let task = Task { try await session.connect(to: peer.device) }
try await until("Receiver launch waits on frozen clock") { try await until("Receiver launch waits on frozen clock") {
peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.milliseconds(400)) peer.commands.contains { $0.0 == "LAUNCH" } && clock.hasDelay(.seconds(30))
} }
clock.advance(.milliseconds(400)) clock.advance(.seconds(30))
do { try await task.value; fatalError("FAIL: unanswered launch succeeded") } do { try await task.value; fatalError("FAIL: unanswered launch succeeded") }
catch CastError.timeout { require(disconnected, "Launch deadline releases channel") } catch CastError.timeout { require(disconnected, "Launch deadline releases channel") }
} else { } else {
@@ -623,11 +756,23 @@ struct CastSessionCheck {
try codec() try codec()
let (identity, directory) = try testIdentity() let (identity, directory) = try testIdentity()
defer { try? FileManager.default.removeItem(at: directory) } defer { try? FileManager.default.removeItem(at: directory) }
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--launch-regressions" {
try await launchRegressions(identity: identity)
print("PASS: cold and asynchronous Cast launch readiness, unrelated app protection, late acknowledgement")
return
}
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--volume-regressions" {
try await volumeRegressions(identity: identity)
print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout")
return
}
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" { if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3]) try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect") print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
} else { } else {
try await behavior(identity: identity) try await behavior(identity: identity)
try await launchRegressions(identity: identity)
try await volumeRegressions(identity: identity)
try await failures(identity: identity) try await failures(identity: identity)
try await connectionDeadline() try await connectionDeadline()
print("PASS: bounded Cast framing, self-signed TLS launch/load/control, correlation, stale ownership, queue, deadlines, heartbeat, cancellation") print("PASS: bounded Cast framing, self-signed TLS launch/load/control, correlation, stale ownership, queue, deadlines, heartbeat, cancellation")
+5 -1
View File
@@ -211,15 +211,19 @@ Open media, then choose the **Cast** icon in the titlebar (or compact controls),
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. Terebi pauses local output before transferring the acknowledged position and play/pause intent to Google's Default Media Receiver. The existing controls, keyboard shortcuts and macOS media commands control remote playback, seeking, volume/mute and resolved-source subtitles. **Play on This Mac** restores receiver-confirmed position and intent; local volume is preserved independently. Connection/load failures return to local playback with an error. Opening another source ends casting and opens it locally. Frame stepping and YouTube quality changes are local-only; automatic quality is suspended while casting.
TLS connection establishment has a twelve-second deadline; cold receiver launch has a separate thirty-second deadline and accepts validated asynchronous readiness without waiting for the original launch acknowledgement. Volume controls retain the latest requested value until its acknowledgement, ignore older feedback, and do not reposition the native slider during a drag. External TV volume changes still update idle controls.
The Mac serves session-only capability URLs over HTTP. Keep it awake and connected, and allow incoming connections through its firewall. The relay exposes only the chosen media and subtitle resources, never arbitrary filesystem paths or a general URL proxy. Disconnect, replacement and quit cancel delivery and invalidate the listening endpoint. 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. Local files, resolved Miruro HLS and YouTube's combined/separate video/audio inputs are delivered as six-second HLS segments. Compatible, independently decodable H.264/AAC is remuxed; other supported inputs use VideoToolbox H.264 (including its platform software fallback) and stereo AAC. Output targets at most **1920×1080 at 30 fps**, H.264 Main/High level 4.1 or lower, with preserved display aspect ratio. Recorded segments are generated on demand, including distant seeks; there is no full-movie download or preprocessing step. Live output keeps eight segments (about 48 seconds) and is not seekable. Audio-only sources are supported.
Recorded delivery retains already-probed upstream inputs and caches eight completed segments (at most 256 MiB). Repeated, HEAD and range requests reuse cached bytes; uncached segments seek the retained inputs rather than reopening and probing both streams. Uncached generation is serialized to keep FFmpeg input ownership simple. GOP preroll, transcoding and receiver buffering can still make a fresh Cast seek slower than local playback.
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. 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. 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.** `make check-cast` exercises a real self-signed TLS loopback receiver, decoded relay output for all three source paths, header/origin isolation, recorded seeking, VOD/live audio continuity, anamorphic video, capability expiry, controlled protocol deadlines/live pacing, and complete player handoff/back-to-Mac recovery. Regression checks cover slow/asynchronous launch, stale volume feedback, native slider tracking and split-stream seek work: repeated segments add no upstream requests, and indexed distant/adjacent seeks add no origin-zero reprobes. A production-paced live HLS smoke also decoded A/V across a segment boundary and shut down. **YouTube Cast playback has been observed on a physical receiver; these fixes, receiver captions and perceived A/V synchronization still need human verification.**
## Resume playback ## Resume playback
+49 -29
View File
@@ -271,6 +271,7 @@ final class CastSession {
private var pending: [Int: Pending] = [:] private var pending: [Int: Pending] = [:]
private var writes: [Data] = [] private var writes: [Data] = []
private var writing = false private var writing = false
private var launchRequest: Int?
private var appTransport: String? private var appTransport: String?
private var appSession: String? private var appSession: String?
private var mediaSession: Int? private var mediaSession: Int?
@@ -291,6 +292,8 @@ final class CastSession {
private var replayTask: Task<Void, Never>? private var replayTask: Task<Void, Never>?
private var replayDesiredPlaying = true private var replayDesiredPlaying = true
private var receiverVolume = 1.0 private var receiverVolume = 1.0
private var pendingVolume: (request: Int, level: Double)?
private var latestVolumeRequest = 0
init(requestTimeout: Duration = .seconds(8), loadTimeout: Duration = .seconds(25), init(requestTimeout: Duration = .seconds(8), loadTimeout: Duration = .seconds(25),
heartbeatInterval: Duration = .seconds(5), heartbeatInterval: Duration = .seconds(5),
@@ -359,31 +362,17 @@ final class CastSession {
private func beginHandshake() { private func beginHandshake() {
do { do {
// TLS establishment and a cold receiver application have separate deadlines.
readyDeadline?.cancel()
readyDeadline = nil
try send(["type": "CONNECT", "origin": [:], "userAgent": "Terebi"], try send(["type": "CONNECT", "origin": [:], "userAgent": "Terebi"],
namespace: CastWire.connection, destination: "receiver-0") namespace: CastWire.connection, destination: "receiver-0")
startHeartbeat() startHeartbeat()
let id = allocateRequest()
launchRequest = id
try request(["type": "LAUNCH", "appId": "CC1AD845"], namespace: CastWire.receiver, try request(["type": "LAUNCH", "appId": "CC1AD845"], namespace: CastWire.receiver,
destination: "receiver-0", operation: "receiver launch") { [weak self] json, error in destination: "receiver-0", operation: "receiver launch", timeout: .seconds(30), id: id) { [weak self] _, error in
guard let self else { return } if let error { self?.fail(error) }
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) } } catch { fail(error as? CastError ?? .protocolError) }
} }
@@ -511,6 +500,7 @@ final class CastSession {
operation: (command["type"] as? String ?? "command").lowercased(), operation: (command["type"] as? String ?? "command").lowercased(),
generation: mediaGeneration, id: id) { [weak self] _, error in generation: mediaGeneration, id: id) { [weak self] _, error in
guard let self else { return } guard let self else { return }
if self.pendingVolume?.request == id { self.pendingVolume = nil; self.publishSeekState() }
if case .cancelled? = error { return } if case .cancelled? = error { return }
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() } if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
if self.pendingPlayback == id { self.pendingPlayback = nil; self.publishSeekState() } if self.pendingPlayback == id { self.pendingPlayback = nil; self.publishSeekState() }
@@ -518,6 +508,11 @@ final class CastSession {
} }
if seeking { pendingSeek = id; publishSeekState() } if seeking { pendingSeek = id; publishSeekState() }
if changingPlayback { pendingPlayback = id; publishSeekState() } if changingPlayback { pendingPlayback = id; publishSeekState() }
if type == "SET_VOLUME", let level = (command["volume"] as? [String: Any])?["level"] as? Double {
latestVolumeRequest = id
pendingVolume = (id, level)
publishSeekState()
}
return true return true
} catch { onError?(error.localizedDescription); return false } } catch { onError?(error.localizedDescription); return false }
} }
@@ -624,21 +619,45 @@ final class CastSession {
} }
if message.namespace == CastWire.receiver, message.source == "receiver-0", type == "RECEIVER_STATUS" { if message.namespace == CastWire.receiver, message.source == "receiver-0", type == "RECEIVER_STATUS" {
if let status = json["status"] as? [String: Any] { if let status = json["status"] as? [String: Any] {
if let volume = status["volume"] as? [String: Any], let olderVolumeReply = id.map { $0 > 0 && $0 < latestVolumeRequest } ?? false
let acknowledgesVolume = correlated && id == pendingVolume?.request
if !olderVolumeReply, pendingVolume == nil || acknowledgesVolume,
let volume = status["volume"] as? [String: Any],
let level = volume["level"] as? Double, level.isFinite { let level = volume["level"] as? Double, level.isFinite {
let muted = volume["muted"] as? Bool ?? false let muted = volume["muted"] as? Bool ?? false
receiverVolume = muted ? 0 : min(1, max(0, level)) receiverVolume = muted ? 0 : min(1, max(0, level))
if let latestStatus { publish(latestStatus, volume: receiverVolume) } if acknowledgesVolume { pendingVolume = nil }
if let latestStatus { publish(latestStatus) }
}
// Receiver readiness can be broadcast before/without the LAUNCH reply.
// Only the requested Default Media Receiver on receiver-0 can satisfy it.
if let launchRequest, 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 {
appTransport = transport
appSession = session
do {
try send(["type": "CONNECT", "origin": [:]], namespace: CastWire.connection, destination: transport)
} catch { fail(error as? CastError ?? .protocolError); return }
self.launchRequest = nil
complete(launchRequest, json: json, error: nil)
let ready = self.ready
self.ready = nil
ready?.resume()
} }
if let appSession, let appTransport { if let appSession, let appTransport {
let apps = status["applications"] as? [[String: Any]] ?? [] let apps = status["applications"] as? [[String: Any]] ?? []
guard apps.contains(where: { $0["sessionId"] as? String == appSession && guard apps.contains(where: { $0["appId"] as? String == "CC1AD845" &&
$0["sessionId"] as? String == appSession &&
$0["transportId"] as? String == appTransport }) else { $0["transportId"] as? String == appTransport }) else {
fail(.rejected("receiver ownership changed")); return fail(.rejected("receiver ownership changed")); return
} }
} }
} }
if correlated, let id { complete(id, json: json, error: nil) } if correlated, let id, id != launchRequest, id != pendingVolume?.request {
complete(id, json: json, error: nil)
}
return return
} }
guard message.namespace == CastWire.media, message.source == appTransport, guard message.namespace == CastWire.media, message.source == appTransport,
@@ -700,7 +719,7 @@ final class CastSession {
let next = CastStatus(position: max(0, position), duration: max(0, duration), let next = CastStatus(position: max(0, position), duration: max(0, duration),
playing: previous?.playing ?? (state == "PLAYING" || state == "BUFFERING"), playing: previous?.playing ?? (state == "PLAYING" || state == "BUFFERING"),
buffering: previous?.buffering ?? (state == "BUFFERING"), ended: previous?.ended ?? (state == "IDLE" && idle == "FINISHED"), buffering: previous?.buffering ?? (state == "BUFFERING"), ended: previous?.ended ?? (state == "IDLE" && idle == "FINISHED"),
volume: receiverVolume, volume: pendingVolume?.level ?? receiverVolume,
seekable: !live && (supported.map { $0 & 2 != 0 } ?? latestStatus?.seekable ?? false), seekable: !live && (supported.map { $0 & 2 != 0 } ?? latestStatus?.seekable ?? false),
subtitle: subtitle, seekPending: pendingSeek != nil, playbackPending: playbackPending) subtitle: subtitle, seekPending: pendingSeek != nil, playbackPending: playbackPending)
latestStatus = next latestStatus = next
@@ -711,9 +730,9 @@ final class CastSession {
} }
} }
private func publish(_ status: CastStatus, volume: Double? = nil) { private func publish(_ status: CastStatus) {
let next = CastStatus(position: status.position, duration: status.duration, playing: status.playing, let next = CastStatus(position: status.position, duration: status.duration, playing: status.playing,
buffering: status.buffering, ended: status.ended, volume: volume ?? status.volume, buffering: status.buffering, ended: status.ended, volume: pendingVolume?.level ?? receiverVolume,
seekable: status.seekable, subtitle: status.subtitle, seekPending: pendingSeek != nil, seekable: status.seekable, subtitle: status.subtitle, seekPending: pendingSeek != nil,
playbackPending: playbackPending) playbackPending: playbackPending)
latestStatus = next latestStatus = next
@@ -783,13 +802,14 @@ final class CastSession {
let requests = pending.values let requests = pending.values
pending.removeAll() pending.removeAll()
writes.removeAll(); writing = false writes.removeAll(); writing = false
appTransport = nil; appSession = nil; mediaSession = nil appTransport = nil; appSession = nil; mediaSession = nil; launchRequest = nil
retiredSessions.removeAll(); contentID = nil; mediaGeneration = nil retiredSessions.removeAll(); contentID = nil; mediaGeneration = nil
retiredLoads.removeAll() retiredLoads.removeAll()
tracks.removeAll(); latestStatus = nil; pendingSeek = nil; loading = false tracks.removeAll(); latestStatus = nil; pendingSeek = nil; loading = false
pendingPlayback = nil pendingPlayback = nil
loadedMedia = nil; loadedTitle = "" loadedMedia = nil; loadedTitle = ""
receiverVolume = 1 receiverVolume = 1
pendingVolume = nil; latestVolumeRequest = 0
ready?.resume(throwing: error ?? CastError.cancelled) ready?.resume(throwing: error ?? CastError.cancelled)
for request in requests { for request in requests {
request.deadline.cancel() request.deadline.cancel()
+21 -1
View File
@@ -1469,7 +1469,7 @@ private struct PlaybackSlider: NSViewRepresentable {
context.coordinator.parent = self context.coordinator.parent = self
slider.minValue = range.lowerBound slider.minValue = range.lowerBound
slider.maxValue = range.upperBound slider.maxValue = range.upperBound
slider.doubleValue = value slider.setModelValue(value)
slider.isEnabled = enabled slider.isEnabled = enabled
slider.editingChanged = onEditingChanged slider.editingChanged = onEditingChanged
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange { if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
@@ -1493,6 +1493,21 @@ private struct PlaybackSlider: NSViewRepresentable {
final class Slider: NSSlider { final class Slider: NSSlider {
var editingChanged: (Bool) -> Void = { _ in } var editingChanged: (Bool) -> Void = { _ in }
private(set) var tracking = false private(set) var tracking = false
func setModelValue(_ value: Double) { if !tracking { doubleValue = value } }
#if PLAYER_CHECK
static func feedbackCheck() -> Bool {
let slider = Slider()
slider.minValue = 0
slider.maxValue = 1
slider.doubleValue = 0.8
slider.tracking = true
slider.setModelValue(0.79)
let heldUserValue = slider.doubleValue == 0.8
slider.tracking = false
slider.setModelValue(0.79)
return heldUserValue && slider.doubleValue == 0.79
}
#endif
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
guard isEnabled else { return } guard isEnabled else { return }
tracking = true tracking = true
@@ -1538,6 +1553,11 @@ private struct PlaybackSlider: NSViewRepresentable {
} }
} }
#if PLAYER_CHECK
@MainActor
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
#endif
@MainActor @MainActor
private struct VideoSurface: NSViewRepresentable { private struct VideoSurface: NSViewRepresentable {
let view: NSView let view: NSView
+55 -11
View File
@@ -146,14 +146,14 @@ struct Input {
} }
}; };
struct Track { struct Track {
std::unique_ptr<Input> input; Input *input;
AVStream *stream = nullptr; AVStream *stream = nullptr;
AVCodecContext *decoder = nullptr; AVCodecContext *decoder = nullptr;
Packet pending; Packet pending;
Frame decoded; Frame decoded;
bool eof = false, draining = false, remux = false; bool eof = false, draining = false, remux = false;
double nextTime = 0; double nextTime = 0;
Track(std::unique_ptr<Input> in, AVMediaType type, double start, bool live) : input(std::move(in)) { Track(Input &in, AVMediaType type, double start, bool live) : input(&in) {
int index = av_find_best_stream(input->format, type, -1, -1, nullptr, 0); int index = av_find_best_stream(input->format, type, -1, -1, nullptr, 0);
if (index < 0) return; if (index < 0) return;
stream = input->format->streams[index]; stream = input->format->streams[index];
@@ -212,7 +212,7 @@ struct Track {
auto p = packet(); auto p = packet();
input->arm(); input->arm();
for (;;) { for (;;) {
require(!Input::interrupt(input.get())); require(!Input::interrupt(input));
int result = av_read_frame(input->format, p.get()); int result = av_read_frame(input->format, p.get());
if (result == AVERROR_EOF) { eof = true; return; } if (result == AVERROR_EOF) { eof = true; return; }
check(result); check(result);
@@ -229,7 +229,7 @@ struct Track {
void nextFrame() { void nextFrame() {
decoded.reset(); auto f = frame(); decoded.reset(); auto f = frame();
for (;;) { for (;;) {
require(!Input::interrupt(input.get())); require(!Input::interrupt(input));
int result = avcodec_receive_frame(decoder, f.get()); int result = avcodec_receive_frame(decoder, f.get());
if (result == 0) { if (result == 0) {
decoded = std::move(f); decoded = std::move(f);
@@ -440,6 +440,7 @@ struct OutputTrack {
} }
}; };
struct Pipeline { struct Pipeline {
std::unique_ptr<Input> videoInput, audioInput;
std::unique_ptr<Track> video, audio; std::unique_ptr<Track> video, audio;
std::unique_ptr<OutputTrack> videoOutput, audioOutput; std::unique_ptr<OutputTrack> videoOutput, audioOutput;
bool live; bool live;
@@ -450,17 +451,32 @@ struct Pipeline {
duration = std::max(source.duration, primary->duration()); duration = std::max(source.duration, primary->duration());
bool hasVideo = av_find_best_stream(primary->format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0) >= 0; 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; bool hasAudio = av_find_best_stream(primary->format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0) >= 0;
if (hasVideo) video = std::make_unique<Track>(std::move(primary), AVMEDIA_TYPE_VIDEO, start, source.live); if (hasVideo) videoInput = std::move(primary);
if (!source.audio.empty()) { if (!source.audio.empty()) {
audio = std::make_unique<Track>(std::make_unique<Input>(stop, source.audio, source.audioHeaders, false, source.restricted, source.live), AVMEDIA_TYPE_AUDIO, start, source.live); audioInput = std::make_unique<Input>(stop, source.audio, source.audioHeaders, false, source.restricted, source.live);
require(audio->stream != nullptr);
} else if (hasAudio) { } else if (hasAudio) {
if (!primary) primary = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live); if (!primary) primary = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
audio = std::make_unique<Track>(std::move(primary), AVMEDIA_TYPE_AUDIO, start, source.live); audioInput = std::move(primary);
} }
reset(start);
if (!source.audio.empty()) require(audio && audio->stream);
require(video || audio); require(video || audio);
if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14); if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14);
} }
void reset(double start) {
videoOutput.reset(); audioOutput.reset();
video.reset(); audio.reset();
// Fresh codecs preserve independent segment/AAC priming; demuxers retain
// their already-probed metadata and seek indexes across random requests.
if (videoInput) {
videoInput->operationDeadline = std::numeric_limits<int64_t>::max();
video = std::make_unique<Track>(*videoInput, AVMEDIA_TYPE_VIDEO, start, live);
}
if (audioInput) {
audioInput->operationDeadline = std::numeric_limits<int64_t>::max();
audio = std::make_unique<Track>(*audioInput, AVMEDIA_TYPE_AUDIO, start, live);
}
}
Segment segment(int64_t id, double start, double end) { Segment segment(int64_t id, double start, double end) {
Mux mux; Mux mux;
if (video) { if (video) {
@@ -507,6 +523,8 @@ struct CastMedia {
Source source; Source source;
std::atomic<bool> cancelled{false}; std::atomic<bool> cancelled{false};
std::mutex lock; std::mutex lock;
std::mutex vodLock;
std::unique_ptr<Pipeline> vodPipeline;
std::condition_variable wake; std::condition_variable wake;
int listener = -1; int listener = -1;
std::vector<int> clients; std::vector<int> clients;
@@ -607,6 +625,32 @@ struct CastMedia {
if (!source.live) text += "#EXT-X-ENDLIST\n"; if (!source.live) text += "#EXT-X-ENDLIST\n";
return {text.begin(), text.end()}; return {text.begin(), text.end()};
} }
std::shared_ptr<const Segment> vodSegment(int64_t id) {
auto cached = [&]() -> std::shared_ptr<const Segment> {
std::lock_guard guard(lock);
auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
return found == segments.end() ? nullptr : *found;
};
if (auto segment = cached()) return segment;
// ponytail: uncached VOD generation is serialized; use a bounded input
// pool only if parallel uncached requests justify duplicate probe state.
std::lock_guard generation(vodLock);
require(!cancelled);
if (auto segment = cached()) return segment;
try {
if (vodPipeline) vodPipeline->reset(id * segmentSeconds);
else vodPipeline = std::make_unique<Pipeline>(source, cancelled, id * segmentSeconds);
auto segment = std::make_shared<const Segment>(vodPipeline->segment(
id, id * segmentSeconds, std::min(duration, (id + 1) * segmentSeconds)));
{ std::lock_guard guard(lock); segments.push_back(segment); while (segments.size() > windowSegments) segments.pop_front(); }
return segment;
} catch (...) {
// A failed demux/decode is never cached or reused. A later receiver
// request can prepare fresh inputs; this request still receives 503.
vodPipeline.reset();
throw;
}
}
void sendAll(int socket, const uint8_t *bytes, size_t size) { void sendAll(int socket, const uint8_t *bytes, size_t size) {
const auto deadline = Clock::now() + std::chrono::seconds(15); const auto deadline = Clock::now() + std::chrono::seconds(15);
while (size) { while (size) {
@@ -682,8 +726,8 @@ struct CastMedia {
if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes); if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes);
} else if (double(id) * segmentSeconds >= duration) status = 404; } else if (double(id) * segmentSeconds >= duration) status = 404;
else { else {
Pipeline pipeline(source, cancelled, id * segmentSeconds); auto segment = vodSegment(id);
data = pipeline.segment(id, id * segmentSeconds, std::min(duration, (id + 1) * segmentSeconds)).bytes; retained = std::shared_ptr<const std::vector<uint8_t>>(segment, &segment->bytes);
} }
} else status = 404; } else status = 404;
response(socket, status, type, retained ? *retained : data, head, range); response(socket, status, type, retained ? *retained : data, head, range);
@@ -728,7 +772,7 @@ extern "C" bool cast_media_start(CastMedia *media, const char *host, char url[51
std::unique_lock guard(media->lock); 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->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); require(media->segments.size() >= 2 && !media->liveFailed && !media->cancelled);
} } else media->vodPipeline = std::move(pipeline);
for (auto &worker : media->servers) worker = std::thread([media] { media->server(); }); 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; 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; return true;
+3 -3
View File
@@ -1,8 +1,8 @@
- [x] add miruro streaming support - [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) - [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 - [ ] add chrome cast support — implemented; verify playback, captions and A/V sync on a physical Cast device
- [ ] fix premature receiver-launch timeout: reports failure even though Chromecast subsequently connects and playback works - [x] fix premature receiver-launch timeout: reports failure even though Chromecast subsequently connects and playback works
- [ ] fix TV volume slider jitter: receiver updates appear to compete with local volume adjustments - [x] fix TV volume slider jitter: receiver updates appear to compete with local volume adjustments
- [ ] investigate and reduce slow buffering after seeking in YouTube videos while casting, compared with local playback - [x] investigate and reduce slow buffering after seeking in YouTube videos while casting, compared with local playback — reuse probed inputs and cache completed segments; confirm perceived improvement on TV
- [ ] in the "choose what to play" screen, we should change the youtube button general streaming - [ ] 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) - 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)