feat: visualize retained network playback buffer

This commit is contained in:
2026-10-11 02:54:01 +02:00
parent 4d4c3e6b0a
commit 5087a0a0f0
8 changed files with 326 additions and 39 deletions
+29 -3
View File
@@ -87,6 +87,13 @@ final class PlayerModel: ObservableObject {
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
var bufferedRange: ClosedRange<Double>? {
guard fileName != nil, canSeek, !seekPending, !state.seek_pending,
state.buffered_start.isFinite, state.buffered_end.isFinite,
state.buffered_start >= 0, state.buffered_end > state.buffered_start,
state.buffered_end <= duration else { return nil }
return state.buffered_start...state.buffered_end
}
var errorText: String? {
if let commandError { return commandError }
guard !isBusy, state.status == PLAYER_ERROR else { return nil }
@@ -592,6 +599,7 @@ private struct PlaybackSlider: NSViewRepresentable {
let range: ClosedRange<Double>
var volume = false
var enabled = true
var bufferedRange: ClosedRange<Double>?
var onEditingChanged: (Bool) -> Void = { _ in }
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -614,6 +622,10 @@ private struct PlaybackSlider: NSViewRepresentable {
slider.doubleValue = value
slider.isEnabled = enabled
slider.editingChanged = onEditingChanged
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
cell.bufferedRange = bufferedRange
slider.needsDisplay = true
}
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
}
@@ -643,6 +655,7 @@ private struct PlaybackSlider: NSViewRepresentable {
final class Cell: NSSliderCell {
var volume = false
var bufferedRange: ClosedRange<Double>?
override func drawBar(inside rect: NSRect, flipped: Bool) {
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
width: max(0, rect.width - knobThickness), height: 3)
@@ -653,6 +666,14 @@ private struct PlaybackSlider: NSViewRepresentable {
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
NSColor.systemOrange.withAlphaComponent(0.85).setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
} else if let bufferedRange, maxValue > minValue {
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
let scale = bar.width / (maxValue - minValue)
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
width: (upper - lower) * scale, height: bar.height)
NSColor.white.withAlphaComponent(0.4).setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
}
override func drawKnob(_ rect: NSRect) {
@@ -832,7 +853,11 @@ private struct PlayerContent: View {
}
private var playbackControls: some View {
VStack(spacing: 8) {
let buffered = model.bufferedRange
let bufferedStatus = buffered.map {
"Buffered through \(time($0.upperBound)) — \(String(format: "%.1f", $0.upperBound - $0.lowerBound)) seconds ahead"
}
return VStack(spacing: 8) {
HStack(spacing: 0) {
HStack(spacing: 4) {
iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill",
@@ -875,12 +900,13 @@ private struct PlayerContent: View {
HStack(spacing: 10) {
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
enabled: model.canSeek, onEditingChanged: { editing in
enabled: model.canSeek, bufferedRange: buffered, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
.accessibilityLabel("Playback Position")
.accessibilityValue(time(model.seekPosition))
.accessibilityValue(time(model.seekPosition) + (bufferedStatus.map { ", " + $0 } ?? ""))
.help(bufferedStatus ?? "Playback Position")
Button {
showsRemainingTime.toggle()
model.revealControls()
+40 -2
View File
@@ -4,8 +4,10 @@ extern "C" {
#include <libavformat/avformat.h>
}
#include <array>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cerrno>
#include <condition_variable>
#include <cstdint>
@@ -48,7 +50,8 @@ public:
operationSerial.store(serial.load(std::memory_order_acquire), std::memory_order_relaxed);
deadline.store(now() + 15'000'000'000LL, std::memory_order_relaxed);
}
void start(std::array<int, 2> indices) {
void start(std::array<int, 2> indices, double timelineOrigin) {
origin = timelineOrigin;
for (int slot = 0; slot < 2; ++slot) {
lanes[slot].index = indices[slot];
if (indices[slot] < 0) continue;
@@ -69,6 +72,7 @@ public:
void seek(int streamIndex, int64_t timestamp) {
std::lock_guard lock(mutex);
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = false;
failure = 0;
seekIndex = streamIndex;
@@ -81,11 +85,13 @@ public:
std::lock_guard lock(mutex);
skipAudio = discard;
if (discard) clear(lanes[0]);
if (discard && scratchSlot == 0) scratchSlot = -1;
changed.notify_all();
}
void finish() {
std::lock_guard lock(mutex);
for (auto &lane : lanes) clear(lane);
scratchSlot = -1;
ended = true;
seeking = false;
failure = 0;
@@ -100,13 +106,23 @@ public:
std::lock_guard lock(mutex);
return failure;
}
double bufferedEnd(int slot) const {
std::lock_guard lock(mutex);
if (failure || seeking) return 0;
const auto &lane = lanes[slot];
const double queued = lane.count ?
lane.ends[(lane.first + lane.count - 1) % packetLimit] : 0;
return std::max(queued, scratchSlot == slot ? scratchEnd : 0);
}
// 1: packet, 0: not yet available, AVERROR_EOF: this input is drained.
int take(int slot, AVPacket *destination) {
int take(int slot, AVPacket *destination, double &bufferedEnd) {
std::lock_guard lock(mutex);
if (failure) return failure;
bufferedEnd = 0;
auto &lane = lanes[slot];
if (lane.count) {
auto *packet = lane.packets[lane.first];
bufferedEnd = lane.ends[lane.first];
lane.bytes -= packetBytes(packet);
av_packet_move_ref(destination, packet);
lane.first = (lane.first + 1) % packetLimit;
@@ -120,6 +136,7 @@ public:
private:
struct Lane {
std::array<AVPacket *, packetLimit> packets{};
std::array<double, packetLimit> ends{};
int index = -1;
std::size_t first = 0, count = 0, bytes = 0;
};
@@ -137,6 +154,8 @@ private:
bool seeking = false, ended = false, skipAudio = false;
int seekIndex = -1, failure = 0;
int64_t seekTimestamp = 0;
int scratchSlot = -1;
double scratchEnd = 0, origin = 0;
std::thread reader;
static int64_t now() {
@@ -153,6 +172,20 @@ private:
}
return bytes;
}
double packetEnd(int slot, const AVPacket *packet) const {
if (packet->pts == AV_NOPTS_VALUE || (packet->flags & AV_PKT_FLAG_DISCARD)) return 0;
int64_t timestamp = packet->pts;
if (slot == 1) {
// PTS alone can jump to a future reference frame before its B-frames arrive.
// Unknown DTS contributes no encoded-video estimate; decoded frames still count.
if (packet->dts == AV_NOPTS_VALUE) return 0;
timestamp = std::min(timestamp, packet->dts);
}
const double tick = av_q2d(format->streams[lanes[slot].index]->time_base);
const double end = static_cast<double>(timestamp) * tick - origin +
static_cast<double>(std::max<int64_t>(packet->duration, 0)) * tick;
return std::isfinite(end) && end > 0 ? end : 0;
}
static void clear(Lane &lane) {
while (lane.count) {
av_packet_unref(lane.packets[lane.first]);
@@ -233,6 +266,8 @@ private:
playerWake.notify_one();
continue;
}
scratchSlot = slot;
scratchEnd = packetEnd(slot, scratch);
changed.wait(lock, [&] {
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
(slot == 0 && skipAudio) ||
@@ -241,11 +276,14 @@ private:
if (stoppingNow()) return;
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
av_packet_unref(scratch);
scratchSlot = -1;
continue;
}
lane.bytes += bytes;
lane.ends[(lane.first + lane.count) % packetLimit] = scratchEnd;
av_packet_move_ref(lane.packets[(lane.first + lane.count) % packetLimit], scratch);
++lane.count;
scratchSlot = -1;
playerWake.notify_one();
}
} catch (...) {
+56 -3
View File
@@ -61,6 +61,8 @@ struct Track {
AVPacket *packet = nullptr;
bool packetPending = false;
bool receiving = false;
double packetBufferedEnd = 0;
double decoderBufferedEnd = 0;
bool sourceEnded = false;
bool drainSent = false;
bool drained = false;
@@ -247,6 +249,8 @@ struct Player {
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;
}
@@ -260,6 +264,11 @@ struct Player {
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();
@@ -298,6 +307,41 @@ struct Player {
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;
state.buffered_start = position;
state.buffered_end = end;
}
void publish() {
std::lock_guard<std::mutex> lock(mutex);
if (activeEpoch != requestedEpoch.load(std::memory_order_relaxed)) return;
@@ -315,6 +359,7 @@ struct Player {
return (command.action == Action::Seek || command.action == Action::Step) &&
command.epoch == activeEpoch;
});
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];
@@ -401,6 +446,7 @@ struct Player {
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();
@@ -562,7 +608,7 @@ struct Player {
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);
input->start(indices, origin);
}
}
status = PLAYER_PAUSED;
@@ -1000,9 +1046,11 @@ struct Player {
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);
@@ -1015,6 +1063,7 @@ struct Player {
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;
}
@@ -1023,7 +1072,7 @@ struct Player {
return true;
}
if (track.input && !track.sourceEnded) {
const int result = track.input->take(slot, track.packet);
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");
@@ -1304,7 +1353,11 @@ PlayerState player_get_state(Player *player) {
try {
if (player) {
std::lock_guard<std::mutex> lock(player->mutex);
return player->state;
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{};
+4
View File
@@ -15,6 +15,10 @@ typedef struct PlayerState {
PlayerStatus status;
double position;
double duration;
// Retained playable network media ahead, on the same timeline as position.
// Empty/unavailable (including local playback and pending seeks) is 0, 0.
double buffered_start;
double buffered_end;
bool has_audio;
bool has_video;
// False for live/non-seekable inputs; reflects both inputs for separate streams.