1562 lines
70 KiB
C++
1562 lines
70 KiB
C++
#include "player.h"
|
|
#include "player_macos.h"
|
|
#include "output.hpp"
|
|
#include "progress_reporter.hpp"
|
|
#include "video_decoder.hpp"
|
|
|
|
extern "C" {
|
|
#include <libavcodec/avcodec.h>
|
|
#include <libavformat/avformat.h>
|
|
#include <libavutil/channel_layout.h>
|
|
#include <libavutil/error.h>
|
|
#include <libavutil/imgutils.h>
|
|
#include <libswresample/swresample.h>
|
|
}
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <condition_variable>
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <limits>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <arpa/inet.h>
|
|
#include <vector>
|
|
#include "network_input.hpp"
|
|
#include "network_policy.hpp"
|
|
|
|
namespace {
|
|
constexpr int sampleRate = 48000;
|
|
constexpr std::size_t commandLimit = 32;
|
|
constexpr std::size_t videoFrameLimit = 64;
|
|
constexpr std::size_t videoByteLimit = 128 * 1024 * 1024;
|
|
constexpr int audioFrameLimit = sampleRate * 4;
|
|
constexpr double sampleTime = 1.0 / sampleRate;
|
|
using Clock = std::chrono::steady_clock;
|
|
|
|
struct Cancelled {};
|
|
struct FrameDeleter {
|
|
void operator()(AVFrame *frame) const { av_frame_free(&frame); }
|
|
};
|
|
using Frame = std::unique_ptr<AVFrame, FrameDeleter>;
|
|
struct VideoFrame {
|
|
Frame frame;
|
|
double pts;
|
|
double end;
|
|
std::size_t bytes;
|
|
uint64_t timestampOccurrence = 0;
|
|
};
|
|
struct Track {
|
|
AVCodecContext *codec = nullptr;
|
|
int index = -1;
|
|
AVFormatContext *format = nullptr;
|
|
playback::NetworkInput *input = nullptr;
|
|
AVPacket *packet = nullptr;
|
|
bool packetPending = false;
|
|
bool receiving = false;
|
|
bool firstPacket = true;
|
|
double packetBufferedEnd = 0;
|
|
double decoderBufferedEnd = 0;
|
|
bool sourceEnded = false;
|
|
bool drainSent = false;
|
|
bool drained = false;
|
|
double nextPts = 0;
|
|
int64_t countedTimestamp = AV_NOPTS_VALUE;
|
|
uint64_t timestampOccurrence = 0;
|
|
AVStream *stream() const { return format->streams[index]; }
|
|
};
|
|
enum class Action { Open, Play, Pause, Seek, Step, Close, Remote };
|
|
struct Command {
|
|
Action action;
|
|
std::string path;
|
|
double seconds = 0;
|
|
int direction = 0;
|
|
uint64_t epoch = 0;
|
|
uint64_t repetitions = 1;
|
|
std::string audioURL{};
|
|
std::string videoHeaders{};
|
|
std::string audioHeaders{};
|
|
double sourceDuration = 0;
|
|
bool network = false;
|
|
bool live = false;
|
|
std::string mediaURL{};
|
|
double initialPosition = 0;
|
|
PlayerState remoteState{};
|
|
};
|
|
|
|
void copyError(char *destination, const char *message) noexcept {
|
|
std::snprintf(destination, 512, "%s", message ? message : "Unknown playback error");
|
|
}
|
|
|
|
int64_t presentationTimestamp(const AVFrame *frame) {
|
|
return frame->best_effort_timestamp != AV_NOPTS_VALUE ?
|
|
frame->best_effort_timestamp : frame->pts;
|
|
}
|
|
|
|
using playback::validNetworkURL;
|
|
using playback::validHeaders;
|
|
using playback::networkOrigin;
|
|
|
|
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
|
|
int flags, AVDictionary **options) noexcept {
|
|
try {
|
|
const auto &input = *static_cast<playback::NetworkInput *>(format->opaque);
|
|
// crypto forwards options to its underlying HTTP protocol; do not unwrap the
|
|
// actual URL, since FFmpeg must still decrypt AES-128 segments itself.
|
|
const char *resource = std::strncmp(url, "crypto+", 7) == 0 ||
|
|
std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
|
|
if (networkOrigin(resource) != input.headerOrigin) return AVERROR(EACCES);
|
|
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
|
if (result >= 0) result = av_dict_set(options, "headers", input.requestHeaders.c_str(), 0);
|
|
// FFmpeg 9's HTTP redirect loop retains custom headers. Reject every redirect
|
|
// before any second request, including same-origin redirects, rather than leak.
|
|
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
|
|
if (result < 0) return result;
|
|
return avio_open2(pb, url, flags, &format->interrupt_callback, options);
|
|
} catch (const std::bad_alloc &) {
|
|
return AVERROR(ENOMEM);
|
|
} catch (...) {
|
|
return AVERROR(EINVAL);
|
|
}
|
|
}
|
|
|
|
void redactedFFmpegLog(void *, int level, const char *, va_list) {
|
|
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
|
|
// FFmpeg diagnostics can interpolate signed URLs, cookies, keys and headers,
|
|
// including at error level. Player::check still exposes operation + AVERROR.
|
|
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg error (private details redacted)\n" :
|
|
"FFmpeg warning (private details redacted)\n", stderr);
|
|
}
|
|
}
|
|
|
|
struct Player {
|
|
playback::Output output;
|
|
std::mutex mutex;
|
|
std::condition_variable wake;
|
|
std::deque<Command> commands;
|
|
PlayerState state{};
|
|
uint64_t publishedEpoch = 0;
|
|
std::array<char, 512> requestedError{};
|
|
bool errorRequested = false;
|
|
uint64_t errorEpoch = 0;
|
|
std::atomic<bool> stopping{false};
|
|
std::atomic<uint64_t> requestedEpoch{0};
|
|
uint64_t activeEpoch = 0;
|
|
|
|
// Decoders, timeline and output are worker-owned; network readers exclusively own demux I/O.
|
|
AVFormatContext *format = nullptr;
|
|
playback::StreamCacheBudget cacheBudget;
|
|
std::array<std::unique_ptr<playback::NetworkInput>, 2> inputs;
|
|
bool network = false;
|
|
bool canSeek = false;
|
|
bool buffering = false;
|
|
std::array<Track, 2> tracks;
|
|
AVPacket *packet = nullptr;
|
|
AVFrame *decoded = nullptr;
|
|
SwrContext *resampler = nullptr;
|
|
AVChannelLayout inputLayout{};
|
|
int inputRate = 0;
|
|
AVSampleFormat inputFormat = AV_SAMPLE_FMT_NONE;
|
|
bool resamplerDrained = false;
|
|
bool packetPending = false;
|
|
bool demuxEnded = false;
|
|
int nextDecodeSlot = 0;
|
|
std::deque<VideoFrame> video;
|
|
std::size_t videoBytes = 0;
|
|
VideoFrame presented{};
|
|
int64_t skipPresentedTimestamp = AV_NOPTS_VALUE;
|
|
uint64_t skipPresentedOccurrence = 0;
|
|
bool frameStepping = false;
|
|
// ponytail: long forward runs can make resume preroll long; refresh verified anchors if that latency matters.
|
|
int64_t steppingAnchor = AV_NOPTS_VALUE;
|
|
std::vector<float> pcm;
|
|
std::size_t pcmFrames = 0;
|
|
std::size_t pcmOffset = 0;
|
|
double pcmPts = 0;
|
|
double resampleNextPts = 0;
|
|
double origin = 0;
|
|
double duration = 0;
|
|
double position = 0;
|
|
double seekTarget = 0;
|
|
double audioWriteEnd = 0;
|
|
double videoEnd = 0;
|
|
double videoFrameTime = 1.0 / 25;
|
|
double steadyPosition = 0;
|
|
Clock::time_point steadyAnchor = Clock::now();
|
|
PlayerStatus status = PLAYER_EMPTY;
|
|
bool previewShown = false;
|
|
std::array<char, 512> error{};
|
|
std::unique_ptr<playback::ProgressReporter> reporter;
|
|
bool remoteReporting = false;
|
|
std::thread worker;
|
|
|
|
Player() : worker([this] { run(); }) {}
|
|
~Player() { shutdown(); }
|
|
|
|
void shutdown() {
|
|
if (!stopping.exchange(true, std::memory_order_relaxed)) {
|
|
requestedEpoch.fetch_add(1, std::memory_order_relaxed);
|
|
wake.notify_one();
|
|
}
|
|
if (worker.joinable()) worker.join();
|
|
if (reporter) reporter->finish();
|
|
}
|
|
|
|
bool enqueue(Command command) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (stopping.load(std::memory_order_relaxed)) return false;
|
|
const bool ready = publishedEpoch == requestedEpoch.load(std::memory_order_relaxed) &&
|
|
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED);
|
|
if (command.action == Action::Seek && ready && !state.can_seek) return false;
|
|
if (command.action == Action::Step && (!ready || !state.has_video || !state.can_seek))
|
|
return false;
|
|
const bool newTimeline = command.action == Action::Open || command.action == Action::Close;
|
|
command.epoch = requestedEpoch.load(std::memory_order_relaxed) + (newTimeline ? 1 : 0);
|
|
if (command.action == Action::Step && !commands.empty()) {
|
|
auto &last = commands.back();
|
|
if (last.action == Action::Step && last.direction == command.direction &&
|
|
last.epoch == command.epoch) {
|
|
if (last.repetitions == std::numeric_limits<uint64_t>::max()) return false;
|
|
++last.repetitions;
|
|
state.seek_pending = true;
|
|
state.buffered_start = state.buffered_end = 0;
|
|
++state.revision;
|
|
wake.notify_one();
|
|
return true;
|
|
}
|
|
}
|
|
if (command.action == Action::Seek && !commands.empty() &&
|
|
commands.back().action == Action::Seek && commands.back().epoch == command.epoch) {
|
|
commands.back().seconds = command.seconds;
|
|
} else {
|
|
if (commands.size() >= commandLimit) return false;
|
|
commands.push_back(std::move(command));
|
|
}
|
|
if (newTimeline) requestedEpoch.store(commands.back().epoch, std::memory_order_relaxed);
|
|
if (commands.back().action == Action::Open) {
|
|
const auto revision = state.revision + 1;
|
|
state = {};
|
|
state.status = PLAYER_OPENING;
|
|
state.revision = revision;
|
|
}
|
|
if (commands.back().action == Action::Seek || commands.back().action == Action::Step ||
|
|
commands.back().action == Action::Close) {
|
|
state.buffered_start = state.buffered_end = 0;
|
|
++state.revision;
|
|
}
|
|
if (commands.back().action == Action::Seek || commands.back().action == Action::Step)
|
|
state.seek_pending = true;
|
|
wake.notify_one();
|
|
return true;
|
|
}
|
|
|
|
void apiError(const char *message) noexcept {
|
|
try {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
copyError(requestedError.data(), message);
|
|
errorRequested = true;
|
|
errorEpoch = requestedEpoch.fetch_add(1, std::memory_order_relaxed) + 1;
|
|
const auto revision = state.revision + 1;
|
|
state = {};
|
|
state.status = PLAYER_ERROR;
|
|
copyError(state.error, message);
|
|
state.revision = revision;
|
|
wake.notify_one();
|
|
} catch (...) {
|
|
// No exception may cross the C boundary, including a failed mutex operation.
|
|
}
|
|
}
|
|
|
|
bool interrupted() const {
|
|
return stopping.load(std::memory_order_relaxed) ||
|
|
requestedEpoch.load(std::memory_order_relaxed) != activeEpoch;
|
|
}
|
|
static int interrupt(void *opaque) {
|
|
return static_cast<Player *>(opaque)->interrupted();
|
|
}
|
|
void check(int result, const char *operation) {
|
|
if (result >= 0) return;
|
|
if (interrupted()) throw Cancelled{};
|
|
char details[AV_ERROR_MAX_STRING_SIZE];
|
|
av_strerror(result, details, sizeof(details));
|
|
throw std::runtime_error(std::string(operation) + ": " + details);
|
|
}
|
|
|
|
double retainedTrackEnd(int slot) const {
|
|
const auto &track = tracks[slot];
|
|
double end = slot == 0 ? std::max(audioWriteEnd, resampleNextPts) : videoEnd;
|
|
if (track.packetPending) end = std::max(end, track.packetBufferedEnd);
|
|
end = std::max(end, track.decoderBufferedEnd);
|
|
if (track.input) end = std::max(end, track.input->bufferedEnd(slot));
|
|
return end;
|
|
}
|
|
|
|
void publishBufferedRange() {
|
|
state.buffered_start = state.buffered_end = 0;
|
|
if (!network || state.seek_pending || frameStepping ||
|
|
(status != PLAYER_PAUSED && status != PLAYER_PLAYING) ||
|
|
!std::isfinite(duration) || duration <= 0 ||
|
|
!std::isfinite(position) || position < 0 || position >= duration) return;
|
|
const bool audio = tracks[0].codec != nullptr;
|
|
const bool video = tracks[1].codec != nullptr;
|
|
if (!audio && !video) return;
|
|
const double audioEnd = audio ? retainedTrackEnd(0) : 0;
|
|
const double videoEnd = video ? retainedTrackEnd(1) : 0;
|
|
double end = audio ? audioEnd : videoEnd;
|
|
if (audio && video) {
|
|
const bool audioFinished = audioDrained();
|
|
const bool videoFinished = tracks[1].drained;
|
|
// Exhausted audio is synthesized as silence; exhausted video holds its last frame.
|
|
// Neither shorter track caps the other's retained tail, but EOF is not a full cache.
|
|
end = audioFinished ? (videoFinished ? std::max(audioEnd, videoEnd) : videoEnd) :
|
|
(videoFinished ? audioEnd : std::min(audioEnd, videoEnd));
|
|
}
|
|
end = std::min(end, duration);
|
|
if (!std::isfinite(end) || end < position) return;
|
|
double start = 0;
|
|
for (int slot = 0; slot < 2; ++slot) {
|
|
const auto &track = tracks[slot];
|
|
if (!track.codec) continue;
|
|
double cached = track.input ? track.input->bufferedStart(slot) : NAN;
|
|
if (!std::isfinite(cached) && slot == 0 && audioDrained()) cached = this->audioEnd();
|
|
start = std::max(start, std::isfinite(cached) ? std::clamp(cached, 0.0, position) : position);
|
|
}
|
|
if (end <= start) return;
|
|
state.buffered_start = start;
|
|
state.buffered_end = end;
|
|
}
|
|
|
|
void publish() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (activeEpoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
|
publishedEpoch = activeEpoch;
|
|
state.status = status;
|
|
state.position = position;
|
|
state.duration = duration;
|
|
state.has_audio = tracks[0].codec != nullptr;
|
|
state.has_video = tracks[1].codec != nullptr;
|
|
state.can_seek = canSeek;
|
|
state.buffering = buffering;
|
|
// Commands remain pending while executing; publication acknowledges only completed work.
|
|
state.seek_pending = networkSeeksPending() ||
|
|
std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
|
|
return (command.action == Action::Seek || command.action == Action::Step) &&
|
|
command.epoch == activeEpoch;
|
|
});
|
|
for (auto &input : inputs) if (input) input->updatePosition(position);
|
|
publishBufferedRange();
|
|
state.video_aspect_ratio = 0;
|
|
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
|
|
const auto &track = tracks[1];
|
|
const auto aspect = av_guess_sample_aspect_ratio(track.format, track.stream(), nullptr);
|
|
state.video_aspect_ratio = double(video->width) / video->height *
|
|
(aspect.num > 0 && aspect.den > 0 ? av_q2d(aspect) : 1);
|
|
}
|
|
std::memcpy(state.error, error.data(), error.size());
|
|
++state.revision;
|
|
if (reporter && !remoteReporting && reportingInputsHealthy()) reporter->observe(state);
|
|
}
|
|
|
|
bool reportingInputsHealthy() const {
|
|
for (const auto &input : inputs) if (input && input->error()) return false;
|
|
return true;
|
|
}
|
|
|
|
void reportBoundary() {
|
|
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{};
|
|
snapshot.status = status;
|
|
snapshot.duration = duration;
|
|
snapshot.has_video = tracks[1].codec != nullptr;
|
|
snapshot.can_seek = canSeek;
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
snapshot.seek_pending = networkSeeksPending() ||
|
|
std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
|
|
return (command.action == Action::Seek || command.action == Action::Step) &&
|
|
command.epoch == activeEpoch;
|
|
});
|
|
}
|
|
snapshot.position = status == PLAYER_PLAYING && !snapshot.seek_pending ? clockPosition() : position;
|
|
reporter->observe(snapshot);
|
|
}
|
|
|
|
void release() {
|
|
output.pause();
|
|
for (auto &input : inputs) if (input) input->stop();
|
|
output.reset();
|
|
video.clear();
|
|
videoBytes = 0;
|
|
presented = {};
|
|
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
|
frameStepping = false;
|
|
steppingAnchor = AV_NOPTS_VALUE;
|
|
swr_free(&resampler);
|
|
av_channel_layout_uninit(&inputLayout);
|
|
for (auto &track : tracks) {
|
|
avcodec_free_context(&track.codec);
|
|
av_packet_free(&track.packet);
|
|
track = {};
|
|
}
|
|
av_packet_free(&packet);
|
|
av_frame_free(&decoded);
|
|
if (!network) avformat_close_input(&format);
|
|
for (auto &input : inputs) input.reset();
|
|
format = nullptr;
|
|
network = canSeek = buffering = false;
|
|
inputRate = 0;
|
|
inputFormat = AV_SAMPLE_FMT_NONE;
|
|
pcmFrames = pcmOffset = 0;
|
|
packetPending = demuxEnded = resamplerDrained = false;
|
|
nextDecodeSlot = 0;
|
|
origin = duration = position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = 0;
|
|
previewShown = false;
|
|
error.fill(0);
|
|
status = PLAYER_EMPTY;
|
|
}
|
|
|
|
void fail(const char *message) {
|
|
// Preserve messages that point into one of this object's own state buffers.
|
|
std::array<char, 512> saved{};
|
|
copyError(saved.data(), message);
|
|
if (reporter) reporter->closeMedia();
|
|
release();
|
|
error = saved;
|
|
status = PLAYER_ERROR;
|
|
publish();
|
|
}
|
|
|
|
void openTrack(int slot, AVFormatContext *source, playback::NetworkInput *input,
|
|
AVMediaType type, int related) {
|
|
const AVCodec *decoder = nullptr;
|
|
int index = av_find_best_stream(source, type, -1, related, &decoder, 0);
|
|
if (index == AVERROR_STREAM_NOT_FOUND) return;
|
|
check(index, type == AVMEDIA_TYPE_AUDIO ? "Finding audio decoder" : "Finding video decoder");
|
|
auto &track = tracks[slot];
|
|
track.index = index;
|
|
track.format = source;
|
|
track.input = input;
|
|
track.packet = av_packet_alloc();
|
|
if (!track.packet) throw std::bad_alloc();
|
|
track.codec = avcodec_alloc_context3(decoder);
|
|
if (!track.codec) throw std::bad_alloc();
|
|
check(avcodec_parameters_to_context(track.codec, source->streams[index]->codecpar),
|
|
"Reading codec parameters");
|
|
track.codec->pkt_timebase = source->streams[index]->time_base;
|
|
// A bounded software decoder pool; FFmpeg owns its internal frame reorder buffer.
|
|
track.codec->thread_count = 2;
|
|
if (type == AVMEDIA_TYPE_VIDEO) playback::preferVideoToolbox(track.codec);
|
|
check(avcodec_open2(track.codec, decoder, nullptr), "Opening decoder");
|
|
}
|
|
|
|
void resetTimeline(double target, bool clearVideo = true) {
|
|
output.pause();
|
|
output.reset(clearVideo);
|
|
for (auto &track : tracks) {
|
|
if (!track.codec) continue;
|
|
avcodec_flush_buffers(track.codec);
|
|
track.codec->skip_frame = AVDISCARD_DEFAULT;
|
|
track.drainSent = track.drained = false;
|
|
track.firstPacket = true;
|
|
av_packet_unref(track.packet);
|
|
track.packetPending = track.receiving = track.sourceEnded = false;
|
|
track.packetBufferedEnd = track.decoderBufferedEnd = 0;
|
|
track.countedTimestamp = AV_NOPTS_VALUE;
|
|
track.timestampOccurrence = 0;
|
|
const AVStream *stream = track.stream();
|
|
track.nextPts = stream->start_time == AV_NOPTS_VALUE ? target :
|
|
std::max(target, stream->start_time * av_q2d(stream->time_base) - origin);
|
|
}
|
|
swr_free(&resampler);
|
|
av_channel_layout_uninit(&inputLayout);
|
|
inputRate = 0;
|
|
inputFormat = AV_SAMPLE_FMT_NONE;
|
|
if (packet) av_packet_unref(packet);
|
|
av_frame_unref(decoded);
|
|
video.clear();
|
|
videoBytes = 0;
|
|
presented = {};
|
|
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
|
frameStepping = false;
|
|
steppingAnchor = AV_NOPTS_VALUE;
|
|
pcmFrames = pcmOffset = 0;
|
|
packetPending = demuxEnded = resamplerDrained = false;
|
|
nextDecodeSlot = 0;
|
|
position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = target;
|
|
previewShown = false;
|
|
steadyPosition = target;
|
|
steadyAnchor = Clock::now();
|
|
}
|
|
|
|
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers,
|
|
bool live, bool resolved) {
|
|
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
|
|
auto &input = *inputs[slot];
|
|
input.format = avformat_alloc_context();
|
|
if (!input.format) throw std::bad_alloc();
|
|
input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input};
|
|
if (resolved) {
|
|
input.headerOrigin = networkOrigin(url.c_str());
|
|
input.requestHeaders = headers;
|
|
input.format->opaque = &input;
|
|
input.format->io_open = resolvedIOOpen;
|
|
}
|
|
AVDictionary *options = nullptr;
|
|
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
|
// Leave room for socket-timeout recovery within the reader's total 15-second deadline.
|
|
if (result >= 0) result = av_dict_set(&options, "rw_timeout", live ? "15000000" : "5000000", 0);
|
|
// Some YouTube CDNs throttle open-ended ranges; FFmpeg continues bounded requests.
|
|
if (result >= 0 && !live) result = av_dict_set(&options, "request_size", "10485760", 0);
|
|
if (result >= 0 && !live) result = av_dict_set(&options, "multiple_requests", "1", 0);
|
|
// Resume seekable inputs at their byte offset; never restart non-range streams or EOF.
|
|
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect", "1", 0);
|
|
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_on_network_error", "1", 0);
|
|
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_max_retries", "3", 0);
|
|
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_max", "3", 0);
|
|
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_total_max", "5", 0);
|
|
// Leave short-seek read-ahead disabled: cache misses must escape stalled responses.
|
|
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
|
|
if (result >= 0 && resolved) result = av_dict_set(&options, "http_persistent", "0", 0);
|
|
// HLS persistent requests bypass io_open (and its origin/header policy).
|
|
if (result >= 0 && resolved) result = av_dict_set(&options, "http_multiple", "0", 0);
|
|
// Resolved HLS may name genuine TS/fMP4 resources as images. Probe their
|
|
// content instead of trusting suffixes; io_open still enforces every URL.
|
|
if (result >= 0 && resolved) result = av_dict_set(&options, "extension_picky", "0", 0);
|
|
if (result < 0) {
|
|
av_dict_free(&options);
|
|
check(result, "Configuring network input");
|
|
}
|
|
input.armIO();
|
|
result = avformat_open_input(&input.format, url.c_str(), nullptr, &options);
|
|
av_dict_free(&options);
|
|
check(result, "Opening network media");
|
|
input.armIO();
|
|
check(avformat_find_stream_info(input.format, nullptr), "Reading network media information");
|
|
return &input;
|
|
}
|
|
|
|
bool networkSeeksPending() const {
|
|
for (const auto &input : inputs)
|
|
if (input && input->seekPending()) return true;
|
|
return false;
|
|
}
|
|
|
|
void open(const Command &command) {
|
|
reportBoundary();
|
|
remoteReporting = false;
|
|
if (reporter) reporter->begin(command.mediaURL, command.live);
|
|
release();
|
|
network = command.network;
|
|
status = PLAYER_OPENING;
|
|
publish();
|
|
if (network) {
|
|
const bool resolved = command.mediaURL.starts_with("miruro:");
|
|
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
|
format = primary->format;
|
|
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
|
if (command.audioURL.empty()) {
|
|
openTrack(0, format, primary, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
|
} else {
|
|
if (!tracks[1].codec)
|
|
throw std::runtime_error("The selected video stream contains no playable video");
|
|
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live, resolved);
|
|
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
|
|
if (!tracks[0].codec)
|
|
throw std::runtime_error("The selected audio stream contains no playable audio");
|
|
}
|
|
} else {
|
|
std::error_code filesystemError;
|
|
if (!std::filesystem::is_regular_file(command.path, filesystemError))
|
|
throw std::runtime_error(filesystemError ? filesystemError.message() :
|
|
"Open requires a local regular media file");
|
|
const std::string localPath = std::filesystem::absolute(command.path).string();
|
|
format = avformat_alloc_context();
|
|
if (!format) throw std::bad_alloc();
|
|
format->interrupt_callback = {interrupt, this};
|
|
AVDictionary *options = nullptr;
|
|
const int optionResult = av_dict_set(&options, "protocol_whitelist", "file", 0);
|
|
if (optionResult < 0) {
|
|
av_dict_free(&options);
|
|
check(optionResult, "Restricting input to local files");
|
|
}
|
|
const int result = avformat_open_input(&format, localPath.c_str(), nullptr, &options);
|
|
av_dict_free(&options);
|
|
check(result, "Opening media");
|
|
check(avformat_find_stream_info(format, nullptr), "Reading media information");
|
|
openTrack(1, format, nullptr, AVMEDIA_TYPE_VIDEO, -1);
|
|
openTrack(0, format, nullptr, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
|
}
|
|
if (!tracks[0].codec && !tracks[1].codec)
|
|
throw std::runtime_error("The source contains no playable audio or video track");
|
|
double first = std::numeric_limits<double>::infinity();
|
|
double measuredDuration = 0;
|
|
bool seekable = true;
|
|
for (int source = 0; source < (inputs[1] ? 2 : 1); ++source) {
|
|
auto *context = network ? inputs[source]->format : format;
|
|
std::array<int, 2> indices{-1, -1};
|
|
for (int slot = 0; slot < 2; ++slot)
|
|
if (tracks[slot].codec && tracks[slot].format == context) indices[slot] = tracks[slot].index;
|
|
for (unsigned i = 0; i < context->nb_streams; ++i)
|
|
if (static_cast<int>(i) != indices[0] && static_cast<int>(i) != indices[1])
|
|
context->streams[i]->discard = AVDISCARD_ALL;
|
|
if (context->start_time != AV_NOPTS_VALUE) {
|
|
first = std::min(first, static_cast<double>(context->start_time) / AV_TIME_BASE);
|
|
} else {
|
|
for (const auto &track : tracks) {
|
|
if (!track.codec || track.format != context) continue;
|
|
const auto *stream = track.stream();
|
|
if (stream->start_time != AV_NOPTS_VALUE)
|
|
first = std::min(first, stream->start_time * av_q2d(stream->time_base));
|
|
}
|
|
}
|
|
if (context->duration != AV_NOPTS_VALUE && context->duration > 0)
|
|
measuredDuration = std::max(measuredDuration, static_cast<double>(context->duration) / AV_TIME_BASE);
|
|
// HLS time seeks reopen segments; byte-seek support on the initial
|
|
// manifest response says nothing about its demuxer's seek capability.
|
|
seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) &&
|
|
(context->iformat == av_find_input_format("hls") ||
|
|
(context->iformat->flags & AVFMT_NOFILE) != 0 ||
|
|
(context->pb && (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0));
|
|
}
|
|
// One origin for both inputs: independently zeroing them would erase real A/V offsets.
|
|
origin = std::isfinite(first) ? first : 0;
|
|
duration = network && command.sourceDuration > 0 ? command.sourceDuration : measuredDuration;
|
|
if (duration == 0 && !command.live) {
|
|
for (const auto &track : tracks) {
|
|
if (!track.codec) continue;
|
|
const AVStream *stream = track.stream();
|
|
if (stream->duration == AV_NOPTS_VALUE || stream->duration <= 0) continue;
|
|
const double start = stream->start_time == AV_NOPTS_VALUE ? origin :
|
|
stream->start_time * av_q2d(stream->time_base);
|
|
duration = std::max(duration, start - origin + stream->duration * av_q2d(stream->time_base));
|
|
}
|
|
}
|
|
canSeek = !command.live && duration > 0 && seekable;
|
|
if (tracks[1].codec) {
|
|
const auto &track = tracks[1];
|
|
const AVRational rate = av_guess_frame_rate(track.format, track.stream(), nullptr);
|
|
videoFrameTime = rate.num > 0 && rate.den > 0 ? av_q2d(av_inv_q(rate)) : 1.0 / 25;
|
|
}
|
|
if (tracks[0].codec) {
|
|
std::string deviceError;
|
|
if (!output.configure_audio(deviceError))
|
|
throw std::runtime_error(deviceError.empty() ? "Cannot configure audio output" : deviceError);
|
|
}
|
|
if (!network) packet = av_packet_alloc();
|
|
decoded = av_frame_alloc();
|
|
if ((!network && !packet) || !decoded) throw std::bad_alloc();
|
|
resetTimeline(0);
|
|
if (network) {
|
|
for (auto &input : inputs) {
|
|
if (!input) continue;
|
|
std::array<int, 2> indices{-1, -1};
|
|
for (int slot = 0; slot < 2; ++slot)
|
|
if (tracks[slot].input == input.get()) indices[slot] = tracks[slot].index;
|
|
input->start(indices, origin, canSeek);
|
|
}
|
|
}
|
|
status = PLAYER_PAUSED;
|
|
if (command.initialPosition > 0 && command.initialPosition < duration && canSeek)
|
|
seek(command.initialPosition, false);
|
|
publish();
|
|
}
|
|
|
|
double clockPosition() const {
|
|
if (status != PLAYER_PLAYING || buffering) return position;
|
|
if (tracks[0].codec) {
|
|
const double audioClock = output.clock();
|
|
return std::isfinite(audioClock) ? std::max(seekTarget, audioClock) : position;
|
|
}
|
|
return steadyPosition + std::chrono::duration<double>(Clock::now() - steadyAnchor).count();
|
|
}
|
|
|
|
void start() {
|
|
if (!format || status == PLAYER_ERROR || status == PLAYER_EMPTY) return;
|
|
if (status == PLAYER_ENDED) {
|
|
if (!canSeek) return;
|
|
seek(0, false);
|
|
}
|
|
if (status == PLAYER_PLAYING) return;
|
|
if (frameStepping) {
|
|
for (auto &input : inputs) if (input) input->discardAudio(false);
|
|
VideoFrame selected = std::move(presented);
|
|
const int64_t timestamp = presentationTimestamp(selected.frame.get());
|
|
const uint64_t occurrence = selected.timestampOccurrence;
|
|
const double selectedPts = selected.pts;
|
|
// Audio was skipped while stepping. Rebuild it once, only on resume.
|
|
seekVideo(steppingAnchor, selectedPts);
|
|
videoEnd = selected.end;
|
|
presentVideo(std::move(selected));
|
|
skipPresentedTimestamp = timestamp;
|
|
skipPresentedOccurrence = occurrence;
|
|
}
|
|
if (!network && tracks[0].codec) {
|
|
std::string deviceError;
|
|
if (!output.start(deviceError))
|
|
throw std::runtime_error(deviceError.empty() ? "Cannot start audio output" : deviceError);
|
|
}
|
|
steadyPosition = position;
|
|
steadyAnchor = Clock::now();
|
|
status = PLAYER_PLAYING;
|
|
buffering = network;
|
|
}
|
|
|
|
void pause() {
|
|
if (status != PLAYER_PLAYING) return;
|
|
position = clockPosition();
|
|
output.pause();
|
|
if (tracks[0].codec) {
|
|
const double frozen = output.clock();
|
|
if (std::isfinite(frozen)) position = std::max(seekTarget, frozen);
|
|
}
|
|
status = PLAYER_PAUSED;
|
|
buffering = false;
|
|
}
|
|
|
|
double audioEnd() const {
|
|
const auto &track = tracks[0];
|
|
if (!track.codec) return NAN;
|
|
const auto *stream = track.stream();
|
|
if (stream->duration != AV_NOPTS_VALUE && stream->duration > 0) {
|
|
const double start = stream->start_time == AV_NOPTS_VALUE ? origin :
|
|
stream->start_time * av_q2d(stream->time_base);
|
|
return start - origin + stream->duration * av_q2d(stream->time_base);
|
|
}
|
|
const auto *context = track.format;
|
|
if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) return NAN;
|
|
const double start = context->start_time == AV_NOPTS_VALUE ? origin :
|
|
static_cast<double>(context->start_time) / AV_TIME_BASE;
|
|
return start - origin + static_cast<double>(context->duration) / AV_TIME_BASE;
|
|
}
|
|
|
|
bool beyondAudio(double target) const {
|
|
const double end = audioEnd();
|
|
return std::isfinite(end) && target + sampleTime * 0.5 >= end;
|
|
}
|
|
|
|
void markAudioTail(double target) {
|
|
if (!network || !beyondAudio(target)) return;
|
|
tracks[0].sourceEnded = true;
|
|
if (tracks[0].input == tracks[1].input && tracks[0].input)
|
|
tracks[0].input->discardAudio(true);
|
|
}
|
|
|
|
void seek(double seconds, bool resume) {
|
|
if (!format || !canSeek) return;
|
|
const double target = duration > 0 ? std::clamp(seconds, 0.0, duration) : std::max(0.0, seconds);
|
|
const long double absolute = std::round((static_cast<long double>(target) + origin) * AV_TIME_BASE);
|
|
if (absolute < -0x1p63L || absolute >= 0x1p63L)
|
|
throw std::runtime_error("Seek target is outside the supported timestamp range");
|
|
output.pause();
|
|
const int64_t timestamp = static_cast<int64_t>(absolute);
|
|
if (network) {
|
|
for (auto &input : inputs) {
|
|
if (!input) continue;
|
|
input->discardAudio(false);
|
|
// EOF is a timeline position, not a demux seek target (HLS rejects it).
|
|
if ((duration > 0 && target >= duration) ||
|
|
(input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target)))
|
|
input->finish();
|
|
else
|
|
input->seek(-1, timestamp);
|
|
}
|
|
} else {
|
|
check(avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(),
|
|
timestamp, timestamp, 0), "Seeking media");
|
|
}
|
|
resetTimeline(target);
|
|
markAudioTail(target);
|
|
buffering = network;
|
|
status = PLAYER_PAUSED;
|
|
if (resume) start();
|
|
}
|
|
|
|
void seekVideo(int64_t timestamp, double target) {
|
|
output.pause();
|
|
if (network) {
|
|
auto &videoTrack = tracks[1];
|
|
videoTrack.input->seek(videoTrack.index, timestamp);
|
|
if (tracks[0].input && tracks[0].input != videoTrack.input) {
|
|
const long double audioTime = std::round((static_cast<long double>(target) + origin) * AV_TIME_BASE);
|
|
if (audioTime < -0x1p63L || audioTime >= 0x1p63L)
|
|
throw std::runtime_error("Audio seek target is outside the supported timestamp range");
|
|
if (beyondAudio(target)) tracks[0].input->finish();
|
|
else tracks[0].input->seek(-1, static_cast<int64_t>(audioTime));
|
|
}
|
|
} else {
|
|
check(av_seek_frame(format, tracks[1].index, timestamp, AVSEEK_FLAG_BACKWARD),
|
|
"Seeking video keyframe");
|
|
}
|
|
// Decoder preroll must not blank the last presented frame between steps.
|
|
resetTimeline(target, false);
|
|
markAudioTail(target);
|
|
buffering = network;
|
|
status = PLAYER_PAUSED;
|
|
}
|
|
|
|
void presentVideo(VideoFrame frame) {
|
|
output.present(frame.frame.get());
|
|
presented = std::move(frame);
|
|
previewShown = true;
|
|
}
|
|
|
|
VideoFrame nextSteppingFrame() {
|
|
while (video.empty() && !tracks[1].drained) {
|
|
if (interrupted()) throw Cancelled{};
|
|
if (!decodeOne(true)) {
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
wake.wait_for(lock, std::chrono::milliseconds(5), [this] { return interrupted(); });
|
|
}
|
|
}
|
|
if (video.empty()) return {};
|
|
VideoFrame frame = std::move(video.front());
|
|
video.pop_front();
|
|
videoBytes -= frame.bytes;
|
|
return frame;
|
|
}
|
|
|
|
void step(int direction) {
|
|
if (!format || !canSeek || !tracks[1].codec) return;
|
|
pause();
|
|
status = PLAYER_PAUSED;
|
|
// Combined inputs must not block video stepping behind an unused audio packet queue.
|
|
if (network && tracks[1].input) tracks[1].input->discardAudio(true);
|
|
// A seek followed immediately by a queued step must first establish its preview.
|
|
while (!presented.frame) {
|
|
auto frame = nextSteppingFrame();
|
|
if (!frame.frame) return;
|
|
if (frame.pts + sampleTime * 0.5 < seekTarget) continue;
|
|
presentVideo(std::move(frame));
|
|
}
|
|
if (frameStepping && direction > 0) {
|
|
// The stepping decoder is already positioned immediately after the displayed frame.
|
|
auto next = nextSteppingFrame();
|
|
if (next.frame) presentVideo(std::move(next));
|
|
position = presented.pts;
|
|
return;
|
|
}
|
|
VideoFrame selected = std::move(presented);
|
|
const int64_t reference = presentationTimestamp(selected.frame.get());
|
|
const uint64_t referenceOccurrence = selected.timestampOccurrence;
|
|
if (reference == AV_NOPTS_VALUE)
|
|
throw std::runtime_error("Frame stepping requires video presentation timestamps");
|
|
const AVStream *stream = tracks[1].stream();
|
|
const long double ticksPerSecond = 1.0L / av_q2d(stream->time_base);
|
|
const long double originTimestamp = std::clamp(std::floor(origin * ticksPerSecond),
|
|
static_cast<long double>(std::numeric_limits<int64_t>::min() + 1),
|
|
static_cast<long double>(std::numeric_limits<int64_t>::max()));
|
|
const int64_t beginning = std::min(reference, std::max(static_cast<int64_t>(originTimestamp),
|
|
stream->start_time == AV_NOPTS_VALUE ? static_cast<int64_t>(originTimestamp) : stream->start_time));
|
|
int64_t target = reference > beginning ? reference - 1 : reference;
|
|
long double backoff = std::max(1.0L, ticksPerSecond);
|
|
for (;;) {
|
|
// FFmpeg's decoder emits presentation order, including delayed B-frames at drain.
|
|
// ponytail: a one-GOP/unindexed file can require a prefix scan; retained frames stay bounded.
|
|
seekVideo(target, 0);
|
|
VideoFrame previous{};
|
|
bool sawReference = false;
|
|
for (;;) {
|
|
auto frame = nextSteppingFrame();
|
|
if (!frame.frame) break;
|
|
const int64_t timestamp = presentationTimestamp(frame.frame.get());
|
|
const bool atReference = timestamp == reference &&
|
|
frame.timestampOccurrence == referenceOccurrence;
|
|
const bool beforeReference = timestamp < reference ||
|
|
(timestamp == reference && frame.timestampOccurrence < referenceOccurrence);
|
|
if (atReference) sawReference = true;
|
|
if (direction > 0) {
|
|
if (!beforeReference && !atReference) {
|
|
if (sawReference) selected = std::move(frame);
|
|
break;
|
|
}
|
|
} else {
|
|
if (!beforeReference) {
|
|
if (previous.frame) {
|
|
// Preserve the adjacent successor for a subsequent forward step.
|
|
videoBytes += frame.bytes;
|
|
video.push_front(std::move(frame));
|
|
}
|
|
break;
|
|
}
|
|
if (timestamp >= beginning) previous = std::move(frame);
|
|
}
|
|
}
|
|
if ((direction > 0 && sawReference) || previous.frame || target == beginning) {
|
|
if (previous.frame) selected = std::move(previous);
|
|
break;
|
|
}
|
|
// Some demuxers land later than requested. Expand the keyframe search, not an FPS guess.
|
|
target = static_cast<int64_t>(std::max(static_cast<long double>(beginning),
|
|
static_cast<long double>(reference) - backoff));
|
|
backoff *= 2;
|
|
}
|
|
if (interrupted()) throw Cancelled{};
|
|
position = selected.pts;
|
|
steppingAnchor = target;
|
|
frameStepping = true;
|
|
buffering = false;
|
|
presentVideo(std::move(selected));
|
|
}
|
|
|
|
void execute(const Command &command) {
|
|
// 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
|
|
// the transition so their acknowledgement still emits a record.
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
reporter->observe(state);
|
|
}
|
|
switch (command.action) {
|
|
case Action::Open: open(command); break;
|
|
case Action::Play: start(); break;
|
|
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;
|
|
}
|
|
publish();
|
|
}
|
|
|
|
double framePts(Track &track, const AVFrame *frame) {
|
|
const int64_t timestamp = presentationTimestamp(frame);
|
|
if (timestamp == AV_NOPTS_VALUE) {
|
|
if (network) throw std::runtime_error("The network stream has no media presentation timestamps");
|
|
return track.nextPts;
|
|
}
|
|
return timestamp * av_q2d(track.stream()->time_base) - origin;
|
|
}
|
|
|
|
void convertAudio() {
|
|
const bool continuing = resampler != nullptr;
|
|
auto &track = tracks[0];
|
|
const int rate = decoded->sample_rate > 0 ? decoded->sample_rate : track.codec->sample_rate;
|
|
const AVChannelLayout *layout = decoded->ch_layout.nb_channels > 0 ?
|
|
&decoded->ch_layout : &track.codec->ch_layout;
|
|
const auto sampleFormat = static_cast<AVSampleFormat>(decoded->format);
|
|
if (rate <= 0 || layout->nb_channels <= 0 || decoded->nb_samples < 0)
|
|
throw std::runtime_error("Invalid decoded audio format");
|
|
if (!resampler) {
|
|
AVChannelLayout stereo = AV_CHANNEL_LAYOUT_STEREO;
|
|
check(swr_alloc_set_opts2(&resampler, &stereo, AV_SAMPLE_FMT_FLT, sampleRate,
|
|
layout, sampleFormat, rate, 0, nullptr), "Allocating audio resampler");
|
|
check(swr_init(resampler), "Initializing audio resampler");
|
|
check(av_channel_layout_copy(&inputLayout, layout), "Saving audio channel layout");
|
|
inputRate = rate;
|
|
inputFormat = sampleFormat;
|
|
} else if (inputRate != rate || inputFormat != sampleFormat ||
|
|
av_channel_layout_compare(&inputLayout, layout) != 0) {
|
|
throw std::runtime_error("Audio format changes within a track are not supported");
|
|
}
|
|
const double timestamp = framePts(track, decoded);
|
|
const double tick = av_q2d(track.stream()->time_base);
|
|
// Some TS muxers round timestamps to milliseconds despite a 90 kHz timebase.
|
|
// Allow two rounded endpoints of jitter; real gaps still insert silence.
|
|
const bool contiguous = continuing &&
|
|
std::abs(timestamp - track.nextPts) <= std::max(tick, 0.002) + 1.0 / rate;
|
|
const int64_t delayBase = static_cast<int64_t>(rate) * sampleRate;
|
|
pcmPts = contiguous ? resampleNextPts :
|
|
timestamp - static_cast<double>(swr_get_delay(resampler, delayBase)) / delayBase;
|
|
track.nextPts = (contiguous ? track.nextPts : timestamp) + static_cast<double>(decoded->nb_samples) / rate;
|
|
const int capacity = swr_get_out_samples(resampler, decoded->nb_samples);
|
|
check(capacity, "Sizing resampled audio");
|
|
if (capacity > audioFrameLimit)
|
|
throw std::runtime_error("Decoded audio block exceeds the four-second buffer limit");
|
|
const auto samples = static_cast<std::size_t>(capacity) * 2;
|
|
if (pcm.size() < samples) pcm.resize(samples);
|
|
uint8_t *destination = reinterpret_cast<uint8_t *>(pcm.data());
|
|
const int count = swr_convert(resampler, &destination, capacity,
|
|
const_cast<const uint8_t **>(decoded->extended_data), decoded->nb_samples);
|
|
check(count, "Resampling audio");
|
|
resampleNextPts = pcmPts + static_cast<double>(count) / sampleRate;
|
|
pcmFrames = static_cast<std::size_t>(count);
|
|
pcmOffset = 0;
|
|
trimAudio();
|
|
}
|
|
|
|
void trimAudio() {
|
|
if (pcmPts < seekTarget) {
|
|
const double skip = std::ceil((seekTarget - pcmPts) * sampleRate - 1e-6);
|
|
pcmOffset = static_cast<std::size_t>(std::min(skip, static_cast<double>(pcmFrames)));
|
|
}
|
|
}
|
|
|
|
void drainResampler() {
|
|
if (!resampler) {
|
|
resamplerDrained = true;
|
|
return;
|
|
}
|
|
const int capacity = swr_get_out_samples(resampler, 0);
|
|
check(capacity, "Sizing final audio");
|
|
if (capacity > audioFrameLimit)
|
|
throw std::runtime_error("Resampler drain exceeds the audio buffer limit");
|
|
// A nonzero destination also lets a resampler with no delayed samples signal completion.
|
|
const auto samples = static_cast<std::size_t>(std::max(capacity, 32)) * 2;
|
|
if (pcm.size() < samples) pcm.resize(samples);
|
|
uint8_t *destination = reinterpret_cast<uint8_t *>(pcm.data());
|
|
const int count = swr_convert(resampler, &destination, std::max(capacity, 32), nullptr, 0);
|
|
check(count, "Draining audio resampler");
|
|
pcmPts = resampleNextPts;
|
|
resampleNextPts += static_cast<double>(count) / sampleRate;
|
|
pcmFrames = static_cast<std::size_t>(count);
|
|
pcmOffset = 0;
|
|
resamplerDrained = count == 0;
|
|
trimAudio();
|
|
}
|
|
|
|
bool writeSilence(double end) {
|
|
if (end <= audioWriteEnd + sampleTime * 0.5) return false;
|
|
static constexpr std::array<float, 4096> silence{};
|
|
const double remaining = std::round((end - audioWriteEnd) * sampleRate);
|
|
const std::size_t frames = static_cast<std::size_t>(std::min(remaining, 2048.0));
|
|
if (frames == 0) return false;
|
|
const std::size_t accepted = output.write(silence.data(), frames, audioWriteEnd);
|
|
audioWriteEnd += static_cast<double>(accepted) / sampleRate;
|
|
return accepted != 0;
|
|
}
|
|
|
|
bool pumpAudio() {
|
|
if (pcmOffset >= pcmFrames) return false;
|
|
const double start = pcmPts + static_cast<double>(pcmOffset) / sampleRate;
|
|
if (start > audioWriteEnd + sampleTime * 0.5) return writeSilence(start);
|
|
if (start < audioWriteEnd - sampleTime * 0.5) {
|
|
const double overlap = std::round((audioWriteEnd - start) * sampleRate);
|
|
const auto skip = static_cast<std::size_t>(std::min(overlap, static_cast<double>(pcmFrames - pcmOffset)));
|
|
pcmOffset += skip;
|
|
if (pcmOffset >= pcmFrames) return true;
|
|
}
|
|
const std::size_t accepted = output.write(pcm.data() + pcmOffset * 2,
|
|
pcmFrames - pcmOffset, audioWriteEnd);
|
|
pcmOffset += accepted;
|
|
audioWriteEnd += static_cast<double>(accepted) / sampleRate;
|
|
return accepted != 0;
|
|
}
|
|
|
|
bool videoFull() const {
|
|
return video.size() >= videoFrameLimit ||
|
|
(!video.empty() && videoBytes > videoByteLimit - video.back().bytes);
|
|
}
|
|
|
|
void queueVideo(bool stepping = false) {
|
|
auto &track = tracks[1];
|
|
const auto transfer = decoded->color_trc == AVCOL_TRC_UNSPECIFIED ?
|
|
track.codec->color_trc : decoded->color_trc;
|
|
if (transfer == AVCOL_TRC_SMPTE2084 || transfer == AVCOL_TRC_ARIB_STD_B67)
|
|
throw std::runtime_error("HDR PQ/HLG video is unsupported: the native view currently supports SDR only");
|
|
const int64_t timestamp = presentationTimestamp(decoded);
|
|
if (stepping && timestamp == AV_NOPTS_VALUE)
|
|
throw std::runtime_error("Frame stepping requires video presentation timestamps");
|
|
const double pts = framePts(track, decoded);
|
|
const double frameDuration = decoded->duration > 0 ?
|
|
decoded->duration * av_q2d(track.stream()->time_base) : videoFrameTime;
|
|
track.nextPts = pts + frameDuration;
|
|
if (decoded->flags & AV_FRAME_FLAG_DISCARD) return;
|
|
if (timestamp != track.countedTimestamp) {
|
|
track.countedTimestamp = timestamp;
|
|
track.timestampOccurrence = 0;
|
|
} else {
|
|
++track.timestampOccurrence;
|
|
}
|
|
if (!stepping && pts + sampleTime * 0.5 < seekTarget) return;
|
|
if (!stepping && skipPresentedTimestamp != AV_NOPTS_VALUE) {
|
|
if (timestamp != AV_NOPTS_VALUE &&
|
|
(timestamp < skipPresentedTimestamp ||
|
|
(timestamp == skipPresentedTimestamp && track.timestampOccurrence <= skipPresentedOccurrence)))
|
|
return;
|
|
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
|
}
|
|
Frame frame(av_frame_alloc());
|
|
if (!frame) throw std::bad_alloc();
|
|
check(playback::refVideoFrame(frame.get(), decoded), "Mapping decoded video");
|
|
const int size = av_image_get_buffer_size(static_cast<AVPixelFormat>(frame->format),
|
|
frame->width, frame->height, 1);
|
|
check(size, "Sizing decoded video");
|
|
const auto bytes = static_cast<std::size_t>(size);
|
|
if (bytes > videoByteLimit)
|
|
throw std::runtime_error("A decoded video frame exceeds the 128 MiB buffer limit");
|
|
videoEnd = std::max(videoEnd, pts + frameDuration);
|
|
if (network && videoBytes + bytes > videoByteLimit)
|
|
throw std::runtime_error("Decoded video exceeds the 128 MiB buffer limit");
|
|
// Local, extremely interleaved files retain nearest frames when audio needs to catch up.
|
|
while (!video.empty() && (video.size() >= videoFrameLimit || videoBytes + bytes > videoByteLimit)) {
|
|
videoBytes -= video.back().bytes;
|
|
video.pop_back();
|
|
}
|
|
VideoFrame item{std::move(frame), pts, pts + frameDuration, bytes, track.timestampOccurrence};
|
|
auto insertion = std::upper_bound(video.begin(), video.end(), pts,
|
|
[](double value, const VideoFrame &entry) { return value < entry.pts; });
|
|
video.insert(insertion, std::move(item));
|
|
videoBytes += bytes;
|
|
}
|
|
|
|
bool decodeOne(bool stepping = false) {
|
|
for (const auto &input : inputs)
|
|
if (input) check(input->error(), "Reading network media");
|
|
// Once a paused network preview is shown, finish audio preroll first.
|
|
// Video remains a fallback so a combined reader can relieve byte pressure.
|
|
const int firstSlot = network && status == PLAYER_PAUSED && previewShown && !stepping ?
|
|
0 : nextDecodeSlot;
|
|
for (int offset = 0; offset < 2; ++offset) {
|
|
const int slot = (firstSlot + offset) % 2;
|
|
auto &track = tracks[slot];
|
|
if (!track.codec || track.drained || (stepping && slot == 0)) continue;
|
|
if (slot == 0 && pcmOffset < pcmFrames) continue;
|
|
if (slot == 1 && videoFull() &&
|
|
(network || !tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING)) continue;
|
|
if (track.receiving) {
|
|
const int result = avcodec_receive_frame(track.codec, decoded);
|
|
if (result == AVERROR(EAGAIN)) {
|
|
track.receiving = false;
|
|
track.decoderBufferedEnd = 0;
|
|
} else if (result == AVERROR_EOF) {
|
|
track.drained = true;
|
|
track.receiving = false;
|
|
track.decoderBufferedEnd = 0;
|
|
} else {
|
|
check(result, "Decoding media");
|
|
if (slot == 0) convertAudio(); else queueVideo(stepping);
|
|
av_frame_unref(decoded);
|
|
}
|
|
nextDecodeSlot = 1 - slot;
|
|
return true;
|
|
}
|
|
if (track.packetPending) {
|
|
if (slot == 1 && track.firstPacket) {
|
|
check(playback::prepareVideoPacket(track.codec, *track.stream()->codecpar, *track.packet),
|
|
"Preparing video decoder preroll");
|
|
track.firstPacket = false;
|
|
}
|
|
if (slot == 1) {
|
|
// Preroll needs reference pictures, not disposable pictures before
|
|
// the target. Restore full decoding before the target packet and
|
|
// for exact frame stepping; never guess when timestamps are absent.
|
|
const auto *packet = track.packet;
|
|
const bool preroll = !network && !stepping && !previewShown && seekTarget > 0 &&
|
|
packet->pts != AV_NOPTS_VALUE &&
|
|
packet->duration > 0 &&
|
|
(double(packet->pts) + packet->duration) * av_q2d(track.stream()->time_base) - origin < seekTarget;
|
|
track.codec->skip_frame = preroll ? AVDISCARD_NONREF : AVDISCARD_DEFAULT;
|
|
}
|
|
const int result = avcodec_send_packet(track.codec, track.packet);
|
|
if (result != AVERROR(EAGAIN)) {
|
|
check(result, "Sending media packet to decoder");
|
|
track.decoderBufferedEnd = std::max(track.decoderBufferedEnd, track.packetBufferedEnd);
|
|
av_packet_unref(track.packet);
|
|
track.packetPending = false;
|
|
}
|
|
track.receiving = true;
|
|
nextDecodeSlot = 1 - slot;
|
|
return true;
|
|
}
|
|
if (track.input && !track.sourceEnded) {
|
|
const int result = track.input->take(slot, track.packet, track.packetBufferedEnd);
|
|
if (result == AVERROR_EOF) track.sourceEnded = true;
|
|
else if (result == 1) track.packetPending = true;
|
|
else check(result, "Reading network media packet");
|
|
if (result != 0) {
|
|
nextDecodeSlot = 1 - slot;
|
|
return true;
|
|
}
|
|
}
|
|
if (track.sourceEnded && !track.drainSent) {
|
|
const int result = avcodec_send_packet(track.codec, nullptr);
|
|
if (result != AVERROR(EAGAIN)) {
|
|
if (result != AVERROR_EOF) check(result, "Draining decoder");
|
|
track.drainSent = true;
|
|
}
|
|
track.receiving = true;
|
|
nextDecodeSlot = 1 - slot;
|
|
return true;
|
|
}
|
|
}
|
|
if (!stepping && pcmOffset >= pcmFrames &&
|
|
(!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
|
|
drainResampler();
|
|
return true;
|
|
}
|
|
if (network || demuxEnded) return false;
|
|
// The local path keeps one reused demux packet and no reader or packet-queue allocation.
|
|
if (packetPending) {
|
|
const int slot = packet->stream_index == tracks[0].index ? 0 : 1;
|
|
if (stepping && slot == 0) {
|
|
av_packet_unref(packet);
|
|
} else {
|
|
auto &track = tracks[slot];
|
|
if (track.packetPending) return false;
|
|
av_packet_move_ref(track.packet, packet);
|
|
track.packetPending = true;
|
|
}
|
|
packetPending = false;
|
|
return true;
|
|
}
|
|
const int result = av_read_frame(format, packet);
|
|
if (result == AVERROR_EOF) {
|
|
demuxEnded = true;
|
|
for (auto &track : tracks) track.sourceEnded = true;
|
|
return true;
|
|
}
|
|
if (result == AVERROR(EAGAIN)) return false;
|
|
check(result, "Reading media packet");
|
|
if ((packet->stream_index != tracks[0].index && packet->stream_index != tracks[1].index) ||
|
|
(stepping && packet->stream_index == tracks[0].index)) {
|
|
av_packet_unref(packet);
|
|
} else {
|
|
packetPending = true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void renderVideo(double now) {
|
|
VideoFrame latest{};
|
|
while (!video.empty() && video.front().pts <= now + 0.002) {
|
|
videoBytes -= video.front().bytes;
|
|
latest = std::move(video.front());
|
|
video.pop_front();
|
|
}
|
|
if (latest.frame) presentVideo(std::move(latest));
|
|
}
|
|
|
|
bool audioDrained() const {
|
|
return (!tracks[0].codec || tracks[0].drained) && resamplerDrained && pcmOffset >= pcmFrames;
|
|
}
|
|
|
|
void updateBuffering() {
|
|
if (!network || status != PLAYER_PLAYING) return;
|
|
const bool seekPending = networkSeeksPending();
|
|
const bool videoRemaining = tracks[1].codec &&
|
|
(!tracks[1].drained || audioWriteEnd + sampleTime < videoEnd);
|
|
const bool audioEmpty = tracks[0].codec && output.queued() == 0 &&
|
|
(!audioDrained() || videoRemaining);
|
|
const bool videoEmpty = tracks[1].codec && !tracks[1].drained && video.empty() &&
|
|
(!presented.frame || position + 0.002 >= presented.end);
|
|
if (!buffering && (seekPending || audioEmpty || videoEmpty)) {
|
|
output.pause();
|
|
if (tracks[0].codec) {
|
|
const double frozen = output.clock();
|
|
if (std::isfinite(frozen)) position = std::max(seekTarget, frozen);
|
|
} else {
|
|
position = std::min(position, std::max(seekTarget, videoEnd));
|
|
}
|
|
buffering = true;
|
|
}
|
|
const bool audioReady = !tracks[0].codec || output.queued() >= 4800 ||
|
|
(audioDrained() && output.queued() > 0) ||
|
|
(audioDrained() && !videoRemaining);
|
|
const bool videoReady = !tracks[1].codec || tracks[1].drained || !video.empty() ||
|
|
(presented.frame && presented.end > position + 0.002);
|
|
if (buffering && !seekPending && audioReady && videoReady) {
|
|
if (tracks[0].codec) {
|
|
std::string deviceError;
|
|
if (!output.start(deviceError))
|
|
throw std::runtime_error(deviceError.empty() ? "Cannot start audio output" : deviceError);
|
|
}
|
|
steadyPosition = position;
|
|
steadyAnchor = Clock::now();
|
|
buffering = false;
|
|
}
|
|
}
|
|
|
|
bool tick() {
|
|
if (!format || interrupted() || status == PLAYER_ENDED || status == PLAYER_ERROR) return false;
|
|
for (const auto &input : inputs)
|
|
if (input) check(input->error(), "Reading network media");
|
|
if (frameStepping) {
|
|
if (network) publish(); // Reader seek completion must still acknowledge a paused step.
|
|
return false;
|
|
}
|
|
bool progressed = pumpAudio();
|
|
if (status == PLAYER_PAUSED) {
|
|
if (!previewShown && !video.empty()) {
|
|
auto frame = std::move(video.front());
|
|
videoBytes -= frame.bytes;
|
|
video.pop_front();
|
|
presentVideo(std::move(frame));
|
|
progressed = true;
|
|
}
|
|
const bool needPreview = tracks[1].codec && !previewShown && !tracks[1].drained;
|
|
const bool needAudio = tracks[0].codec && output.queued() < 4800 && !audioDrained();
|
|
if (needPreview || needAudio) progressed = decodeOne() || progressed;
|
|
if (network) {
|
|
buffering = networkSeeksPending() || needPreview ||
|
|
(needAudio && output.queued() == 0);
|
|
publish();
|
|
}
|
|
return progressed;
|
|
}
|
|
position = clockPosition();
|
|
renderVideo(position);
|
|
progressed = decodeOne() || progressed;
|
|
// Audio ending first is not a global demux EOF: keep the audio-master clock through video's tail.
|
|
if (tracks[0].codec && audioDrained())
|
|
progressed = writeSilence(videoEnd) || progressed;
|
|
updateBuffering();
|
|
const bool drained = audioDrained() && (!tracks[1].codec || tracks[1].drained);
|
|
if (drained && video.empty()) {
|
|
const double end = std::max(audioWriteEnd, videoEnd);
|
|
if (position + sampleTime >= end && (!tracks[0].codec || output.queued() == 0)) {
|
|
output.pause();
|
|
position = end;
|
|
if (duration <= 0) duration = end;
|
|
buffering = false;
|
|
status = PLAYER_ENDED;
|
|
}
|
|
}
|
|
publish();
|
|
return progressed;
|
|
}
|
|
|
|
void run() noexcept {
|
|
while (!stopping.load(std::memory_order_relaxed)) {
|
|
try {
|
|
Command command{Action::Pause, {}};
|
|
bool haveCommand = false;
|
|
std::array<char, 512> apiFailure{};
|
|
bool haveFailure = false;
|
|
uint64_t failureEpoch = 0;
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (errorRequested) {
|
|
apiFailure = requestedError;
|
|
failureEpoch = errorEpoch;
|
|
errorRequested = false;
|
|
haveFailure = true;
|
|
} else if (!commands.empty()) {
|
|
auto &next = commands.front();
|
|
if (next.action == Action::Step && next.repetitions > 1 &&
|
|
next.epoch == requestedEpoch.load(std::memory_order_relaxed)) {
|
|
command = {Action::Step, {}, 0, next.direction, next.epoch};
|
|
--next.repetitions;
|
|
} else {
|
|
command = std::move(next);
|
|
commands.pop_front();
|
|
}
|
|
haveCommand = true;
|
|
}
|
|
}
|
|
if (haveFailure) {
|
|
if (failureEpoch == requestedEpoch.load(std::memory_order_relaxed)) {
|
|
activeEpoch = failureEpoch;
|
|
fail(apiFailure.data());
|
|
}
|
|
continue;
|
|
}
|
|
if (haveCommand) {
|
|
execute(command);
|
|
continue;
|
|
}
|
|
if (tick()) continue;
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
wake.wait_for(lock, std::chrono::milliseconds(status == PLAYER_PLAYING ? 5 : 20),
|
|
[this] { return stopping.load(std::memory_order_relaxed) || errorRequested || !commands.empty(); });
|
|
} catch (const Cancelled &) {
|
|
// An accepted open/close or destruction superseded the current I/O.
|
|
} catch (const std::exception &exception) {
|
|
try { fail(exception.what()); } catch (...) {}
|
|
} catch (...) {
|
|
try { fail("Unexpected playback failure"); } catch (...) {}
|
|
}
|
|
}
|
|
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();
|
|
} catch (...) {}
|
|
}
|
|
};
|
|
|
|
namespace {
|
|
template <typename Function>
|
|
bool submit(Player *player, Function function) noexcept {
|
|
if (!player) return false;
|
|
try {
|
|
return function();
|
|
} catch (const std::exception &exception) {
|
|
player->apiError(exception.what());
|
|
} catch (...) {
|
|
player->apiError("Unexpected command failure");
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
extern "C" {
|
|
Player *player_create(void) {
|
|
try {
|
|
static std::once_flag logging;
|
|
std::call_once(logging, [] { av_log_set_callback(redactedFFmpegLog); });
|
|
return new Player;
|
|
} catch (...) { return nullptr; }
|
|
}
|
|
bool player_set_progress_file(Player *player, const char *path, char *error) {
|
|
if (error) error[0] = '\0';
|
|
try {
|
|
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
|
|
std::lock_guard<std::mutex> lock(player->mutex);
|
|
if (player->reporter || player->stopping.load(std::memory_order_relaxed) ||
|
|
player->state.status != PLAYER_EMPTY || !player->commands.empty())
|
|
throw std::runtime_error("Progress reporting must be initialized once, before opening media");
|
|
player->reporter = std::make_unique<playback::ProgressReporter>(path);
|
|
return true;
|
|
} catch (const std::exception &failure) {
|
|
if (error) copyError(error, failure.what());
|
|
} catch (...) {
|
|
if (error) copyError(error, "Could not initialize progress reporting");
|
|
}
|
|
return false;
|
|
}
|
|
bool player_get_progress_error(Player *player, char *error) {
|
|
if (error) error[0] = '\0';
|
|
if (!player || !player->reporter) return false;
|
|
const auto failure = player->reporter->error();
|
|
if (failure.empty()) return false;
|
|
if (error) copyError(error, failure.c_str());
|
|
return true;
|
|
}
|
|
bool player_shutdown(Player *player, char *progress_error) {
|
|
if (progress_error) progress_error[0] = '\0';
|
|
if (!player) return false;
|
|
player->shutdown();
|
|
return !player_get_progress_error(player, progress_error);
|
|
}
|
|
bool player_open(Player *player, const char *path) {
|
|
if (!path || !*path) return false;
|
|
return submit(player, [=] { return player->enqueue({Action::Open, path}); });
|
|
}
|
|
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
|
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
|
|
!std::isfinite(source->initial_position) || source->initial_position < 0 ||
|
|
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaIdentity(source->media_url)) ||
|
|
!validNetworkURL(source->video_url) ||
|
|
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
|
|
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
|
|
return submit(player, [=] {
|
|
Command command{Action::Open, source->video_url};
|
|
command.audioURL = source->audio_url ? source->audio_url : "";
|
|
command.videoHeaders = source->video_headers ? source->video_headers : "";
|
|
command.audioHeaders = source->audio_headers ? source->audio_headers : "";
|
|
command.sourceDuration = source->duration;
|
|
command.network = true;
|
|
command.live = source->is_live;
|
|
command.mediaURL = source->media_url ? source->media_url : "";
|
|
command.initialPosition = source->initial_position;
|
|
return player->enqueue(std::move(command));
|
|
});
|
|
}
|
|
bool player_play(Player *player) {
|
|
return submit(player, [=] { return player->enqueue({Action::Play, {}}); });
|
|
}
|
|
bool player_pause(Player *player) {
|
|
return submit(player, [=] { return player->enqueue({Action::Pause, {}}); });
|
|
}
|
|
bool player_seek(Player *player, double seconds) {
|
|
if (!std::isfinite(seconds)) return false;
|
|
return submit(player, [=] { return player->enqueue({Action::Seek, {}, seconds}); });
|
|
}
|
|
bool player_step_frame(Player *player, int direction) {
|
|
if (direction != -1 && direction != 1) return false;
|
|
return submit(player, [=] { return player->enqueue({Action::Step, {}, 0, 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, [=] {
|
|
player->output.set_volume(normalized0to1);
|
|
return true;
|
|
});
|
|
}
|
|
float player_get_volume(Player *player) {
|
|
return player ? player->output.volume() : 0;
|
|
}
|
|
PlayerState player_get_state(Player *player) {
|
|
try {
|
|
if (player) {
|
|
std::lock_guard<std::mutex> lock(player->mutex);
|
|
PlayerState result = player->state;
|
|
if (result.seek_pending ||
|
|
player->publishedEpoch != player->requestedEpoch.load(std::memory_order_relaxed))
|
|
result.buffered_start = result.buffered_end = 0;
|
|
return result;
|
|
}
|
|
} catch (const std::exception &exception) {
|
|
PlayerState result{};
|
|
result.status = PLAYER_ERROR;
|
|
copyError(result.error, exception.what());
|
|
return result;
|
|
} catch (...) {
|
|
PlayerState result{};
|
|
result.status = PLAYER_ERROR;
|
|
copyError(result.error, "Cannot read playback state");
|
|
return result;
|
|
}
|
|
PlayerState result{};
|
|
result.status = PLAYER_ERROR;
|
|
copyError(result.error, "Invalid player handle");
|
|
return result;
|
|
}
|
|
void *player_macos_make_video_view(Player *player) {
|
|
if (!player) return nullptr;
|
|
try {
|
|
return player->output.make_video_view();
|
|
} catch (const std::exception &exception) {
|
|
player->apiError(exception.what());
|
|
} catch (...) {
|
|
player->apiError("Cannot create the native video view");
|
|
}
|
|
return nullptr;
|
|
}
|
|
void player_destroy(Player *player) {
|
|
try { delete player; } catch (...) {}
|
|
}
|
|
}
|