feat: add Chromecast playback support

This commit is contained in:
2026-10-13 17:31:37 +02:00
parent 74b4bfe6c1
commit bb777b0ecd
22 changed files with 3299 additions and 115 deletions
+43 -88
View File
@@ -33,6 +33,7 @@ extern "C" {
#include <arpa/inet.h>
#include <vector>
#include "network_input.hpp"
#include "network_policy.hpp"
namespace {
constexpr int sampleRate = 48000;
@@ -73,7 +74,7 @@ struct Track {
uint64_t timestampOccurrence = 0;
AVStream *stream() const { return format->streams[index]; }
};
enum class Action { Open, Play, Pause, Seek, Step, Close };
enum class Action { Open, Play, Pause, Seek, Step, Close, Remote };
struct Command {
Action action;
std::string path;
@@ -89,6 +90,7 @@ struct Command {
bool live = false;
std::string mediaURL{};
double initialPosition = 0;
PlayerState remoteState{};
};
void copyError(char *destination, const char *message) noexcept {
@@ -100,90 +102,9 @@ int64_t presentationTimestamp(const AVFrame *frame) {
frame->best_effort_timestamp : frame->pts;
}
bool validNetworkURL(const char *text) {
if (!text) return false;
const std::string_view url(text);
if (url.size() > 65536) return false;
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
if (!start) return false;
for (unsigned char value : url)
if (value <= 32 || value == 127 || value == '\\') return false;
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
auto host = authority;
std::string_view port;
if (host.front() == '[') {
const auto end = host.find(']');
if (end == std::string_view::npos || end == 1) return false;
if (end + 1 < host.size()) {
if (host[end + 1] != ':') return false;
port = host.substr(end + 2);
if (port.empty()) return false;
}
host = host.substr(1, end - 1);
std::array<char, INET6_ADDRSTRLEN> address{};
if (host.size() >= address.size()) return false;
std::memcpy(address.data(), host.data(), host.size());
in6_addr parsed{};
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
} else {
const auto colon = host.find(':');
if (colon != std::string_view::npos) {
port = host.substr(colon + 1);
host = host.substr(0, colon);
if (port.empty()) return false;
}
if (host.empty()) return false;
for (unsigned char value : host)
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
}
unsigned number = 0;
for (unsigned char value : port) {
if (value < '0' || value > '9') return false;
number = number * 10 + value - '0';
if (number > 65535) return false;
}
return port.empty() || number > 0;
}
bool validHeaders(const char *text) {
if (!text) return true;
const std::string_view headers(text);
if (headers.size() > 65536) return false;
std::size_t offset = 0;
while (offset < headers.size()) {
const auto end = headers.find("\r\n", offset);
if (end == std::string_view::npos) return false;
const auto line = headers.substr(offset, end - offset);
const auto colon = line.find(':');
if (colon == std::string_view::npos || colon == 0) return false;
for (unsigned char value : line.substr(0, colon))
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') ||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
for (unsigned char value : line.substr(colon + 1))
if (value < 32 || value == 127) return false;
offset = end + 2;
}
return true;
}
std::string networkOrigin(const char *text) {
if (!validNetworkURL(text)) return {};
std::string_view url(text);
const auto start = url.starts_with("https://") ? 8u : 7u;
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
for (char &value : authority)
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
else {
// Canonicalize explicit default ports and leading zeroes.
authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
}
return std::string(url.substr(0, start)) + authority;
}
using playback::validNetworkURL;
using playback::validHeaders;
using playback::networkOrigin;
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
int flags, AVDictionary **options) noexcept {
@@ -275,6 +196,7 @@ struct Player {
bool previewShown = false;
std::array<char, 512> error{};
std::unique_ptr<playback::ProgressReporter> reporter;
bool remoteReporting = false;
std::thread worker;
Player() : worker([this] { run(); }) {}
@@ -436,7 +358,7 @@ struct Player {
}
std::memcpy(state.error, error.data(), error.size());
++state.revision;
if (reporter && reportingInputsHealthy()) reporter->observe(state);
if (reporter && !remoteReporting && reportingInputsHealthy()) reporter->observe(state);
}
bool reportingInputsHealthy() const {
@@ -445,7 +367,7 @@ struct Player {
}
void reportBoundary() {
if (!reporter || !reportingInputsHealthy()) return;
if (!reporter || remoteReporting || !reportingInputsHealthy()) return;
// This is the old worker-owned timeline, even if a new open has already
// replaced the public state with OPENING. Never sample a queued seek target.
PlayerState snapshot{};
@@ -624,6 +546,7 @@ struct Player {
void open(const Command &command) {
reportBoundary();
remoteReporting = false;
if (reporter) reporter->begin(command.mediaURL, command.live);
release();
network = command.network;
@@ -977,7 +900,9 @@ struct Player {
}
void execute(const Command &command) {
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
// Receiver facts precede the next open/close even when it supersedes decoder I/O.
if (command.action == Action::Remote ? command.epoch != activeEpoch :
command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
activeEpoch = command.epoch;
if (reporter && (command.action == Action::Seek || command.action == Action::Step)) {
// Local seeks can finish without a pending publication. Preserve
@@ -991,9 +916,18 @@ struct Player {
case Action::Pause: pause(); break;
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
case Action::Step: step(command.direction); break;
case Action::Remote:
if (!remoteReporting) {
reportBoundary();
release();
}
remoteReporting = true;
if (reporter) reporter->observe(command.remoteState);
return;
case Action::Close:
reportBoundary();
if (reporter) reporter->closeMedia();
remoteReporting = false;
release();
break;
}
@@ -1418,6 +1352,15 @@ struct Player {
}
}
try {
if (reporter) {
std::lock_guard<std::mutex> lock(mutex);
for (const auto &command : commands) {
if (command.action == Action::Remote && command.epoch == activeEpoch) {
reporter->observe(command.remoteState);
remoteReporting = true;
}
}
}
reportBoundary();
if (reporter) reporter->closeMedia();
release();
@@ -1520,6 +1463,18 @@ bool player_step_frame(Player *player, int direction) {
bool player_close(Player *player) {
return submit(player, [=] { return player->enqueue({Action::Close, {}}); });
}
bool player_report_remote_state(Player *player, const PlayerState *state) {
if (!state || !std::isfinite(state->position) || state->position < 0 ||
!std::isfinite(state->duration) || state->duration < 0 ||
(state->status != PLAYER_PAUSED && state->status != PLAYER_PLAYING &&
state->status != PLAYER_ENDED)) return false;
const PlayerState snapshot = *state;
return submit(player, [=] {
Command command{Action::Remote, {}};
command.remoteState = snapshot;
return player->enqueue(std::move(command));
});
}
bool player_set_volume(Player *player, float normalized0to1) {
if (!std::isfinite(normalized0to1) || normalized0to1 < 0 || normalized0to1 > 1) return false;
return submit(player, [=] {