#include "player.h" #include "player_macos.h" #include "output.hpp" extern "C" { #include #include #include #include #include #include } #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "network_input.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; 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; 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 }; 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; }; 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; } bool validNetworkURL(const char *text) { if (!text) return false; const std::string_view url(text); if (url.size() > 65536) return false; const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0; if (!start) return false; for (unsigned char value : url) if (value <= 32 || value == 127 || value == '\\') return false; const auto authority = url.substr(start, url.find_first_of("/?#", start) - start); if (authority.empty() || authority.find('@') != std::string_view::npos) return false; auto host = authority; std::string_view port; if (host.front() == '[') { const auto end = host.find(']'); if (end == std::string_view::npos || end == 1) return false; if (end + 1 < host.size()) { if (host[end + 1] != ':') return false; port = host.substr(end + 2); if (port.empty()) return false; } host = host.substr(1, end - 1); std::array address{}; if (host.size() >= address.size()) return false; std::memcpy(address.data(), host.data(), host.size()); in6_addr parsed{}; if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false; } else { const auto colon = host.find(':'); if (colon != std::string_view::npos) { port = host.substr(colon + 1); host = host.substr(0, colon); if (port.empty()) return false; } if (host.empty()) return false; for (unsigned char value : host) if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || (value >= '0' && value <= '9') || value == '.' || value == '-')) return false; } unsigned number = 0; for (unsigned char value : port) { if (value < '0' || value > '9') return false; number = number * 10 + value - '0'; if (number > 65535) return false; } return port.empty() || number > 0; } bool validHeaders(const char *text) { if (!text) return true; const std::string_view headers(text); if (headers.size() > 65536) return false; std::size_t offset = 0; while (offset < headers.size()) { const auto end = headers.find("\r\n", offset); if (end == std::string_view::npos) return false; const auto line = headers.substr(offset, end - offset); const auto colon = line.find(':'); if (colon == std::string_view::npos || colon == 0) return false; for (unsigned char value : line.substr(0, colon)) if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || (value >= '0' && value <= '9') || std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false; for (unsigned char value : line.substr(colon + 1)) if (value < 32 || value == 127) return false; offset = end + 2; } return true; } } struct Player { playback::Output output; std::mutex mutex; std::condition_variable wake; std::deque commands; PlayerState state{}; uint64_t publishedEpoch = 0; std::array requestedError{}; bool errorRequested = false; uint64_t errorEpoch = 0; std::atomic stopping{false}; std::atomic 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, 2> inputs; bool network = false; bool canSeek = false; bool buffering = false; std::array 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 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 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 error{}; std::thread worker; Player() : worker([this] { run(); }) {} ~Player() { stopping.store(true, std::memory_order_relaxed); requestedEpoch.fetch_add(1, std::memory_order_relaxed); wake.notify_one(); if (worker.joinable()) worker.join(); } bool enqueue(Command command) { std::lock_guard 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::max()) return false; ++last.repetitions; state.seek_pending = true; state.buffered_start = state.buffered_end = 0; ++state.revision; wake.notify_one(); return true; } } 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 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(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 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; } 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 saved{}; copyError(saved.data(), message); 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; 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.drainSent = track.drained = false; 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) { inputs[slot] = std::make_unique(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}; AVDictionary *options = nullptr; int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0); if (result >= 0) result = av_dict_set(&options, "rw_timeout", "15000000", 0); if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 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) { release(); network = command.network; status = PLAYER_OPENING; publish(); if (network) { auto *primary = openNetworkInput(0, command.path, command.videoHeaders); 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); 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::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 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(i) != indices[0] && static_cast(i) != indices[1]) context->streams[i]->discard = AVDISCARD_ALL; if (context->start_time != AV_NOPTS_VALUE) { first = std::min(first, static_cast(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(context->duration) / AV_TIME_BASE); // Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks. seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) && (context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 : (context->iformat->flags & AVFMT_NOFILE) != 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 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; 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(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(context->start_time) / AV_TIME_BASE; return start - origin + static_cast(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(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(absolute); if (network) { for (auto &input : inputs) { if (!input) continue; input->discardAudio(false); if (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::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(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(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 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(std::numeric_limits::min() + 1), static_cast(std::numeric_limits::max())); const int64_t beginning = std::min(reference, std::max(static_cast(originTimestamp), stream->start_time == AV_NOPTS_VALUE ? static_cast(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(std::max(static_cast(beginning), static_cast(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) { if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return; activeEpoch = command.epoch; 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::Close: 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(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); const bool contiguous = continuing && std::abs(timestamp - track.nextPts) <= tick + 1.0 / rate; // Quantized packet PTS and rounded resampler delay must not insert/drop PCM samples. const int64_t delayBase = static_cast(rate) * sampleRate; pcmPts = contiguous ? resampleNextPts : timestamp - static_cast(swr_get_delay(resampler, delayBase)) / delayBase; track.nextPts = (contiguous ? track.nextPts : timestamp) + static_cast(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(capacity) * 2; if (pcm.size() < samples) pcm.resize(samples); uint8_t *destination = reinterpret_cast(pcm.data()); const int count = swr_convert(resampler, &destination, capacity, const_cast(decoded->extended_data), decoded->nb_samples); check(count, "Resampling audio"); resampleNextPts = pcmPts + static_cast(count) / sampleRate; pcmFrames = static_cast(count); pcmOffset = 0; trimAudio(); } void trimAudio() { if (pcmPts < seekTarget) { const double skip = std::ceil((seekTarget - pcmPts) * sampleRate - 1e-6); pcmOffset = static_cast(std::min(skip, static_cast(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::max(capacity, 32)) * 2; if (pcm.size() < samples) pcm.resize(samples); uint8_t *destination = reinterpret_cast(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(count) / sampleRate; pcmFrames = static_cast(count); pcmOffset = 0; resamplerDrained = count == 0; trimAudio(); } bool writeSilence(double end) { if (end <= audioWriteEnd + sampleTime * 0.5) return false; static constexpr std::array silence{}; const double remaining = std::round((end - audioWriteEnd) * sampleRate); const std::size_t frames = static_cast(std::min(remaining, 2048.0)); if (frames == 0) return false; const std::size_t accepted = output.write(silence.data(), frames, audioWriteEnd); audioWriteEnd += static_cast(accepted) / sampleRate; return accepted != 0; } bool pumpAudio() { if (pcmOffset >= pcmFrames) return false; const double start = pcmPts + static_cast(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::min(overlap, static_cast(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(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; } const int size = av_image_get_buffer_size(static_cast(decoded->format), decoded->width, decoded->height, 1); check(size, "Sizing decoded video"); const auto bytes = static_cast(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(); } Frame frame(av_frame_clone(decoded)); if (!frame) throw std::bad_alloc(); 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"); for (int offset = 0; offset < 2; ++offset) { const int slot = (nextDecodeSlot + 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) { 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 apiFailure{}; bool haveFailure = false; uint64_t failureEpoch = 0; { std::lock_guard 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 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 { release(); } catch (...) {} } }; namespace { template 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 { return new Player; } catch (...) { return nullptr; } } 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 || !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; 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_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 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 (...) {} } }