feat: retain streaming media and add opt-in automatic quality
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
// The caller supplies monotonic seconds, keeping the policy independent of timers and extraction.
|
||||
struct AutomaticQuality {
|
||||
struct Sample {
|
||||
var enabled = true
|
||||
var network = true
|
||||
var playing = true
|
||||
var opening = false
|
||||
var seeking = false
|
||||
var live = false
|
||||
var buffering = false
|
||||
var position: Double
|
||||
var duration: Double
|
||||
var ahead: Double?
|
||||
var height: Int?
|
||||
var heights: [Int]
|
||||
}
|
||||
|
||||
private var lowSince: Double?
|
||||
private var healthySince: Double?
|
||||
private var stallSince: Double?
|
||||
private var lastPosition: Double?
|
||||
private var lastProgress: Double?
|
||||
private var notBefore = -Double.infinity
|
||||
|
||||
mutating func resetHealth() {
|
||||
lowSince = nil
|
||||
healthySince = nil
|
||||
stallSince = nil
|
||||
lastPosition = nil
|
||||
lastProgress = nil
|
||||
}
|
||||
|
||||
mutating func extractionFailed(at now: Double) {
|
||||
resetHealth()
|
||||
notBefore = max(notBefore, now + 120)
|
||||
}
|
||||
|
||||
mutating func choose(_ sample: Sample, at now: Double) -> Int? {
|
||||
guard sample.enabled, sample.network, sample.playing, !sample.opening, !sample.seeking,
|
||||
sample.position.isFinite, let height = sample.height, height > 0 else {
|
||||
resetHealth()
|
||||
return nil
|
||||
}
|
||||
guard !sample.heights.isEmpty,
|
||||
sample.live || !sample.duration.isFinite || sample.duration <= 0 ||
|
||||
sample.duration - sample.position > 15 else {
|
||||
resetHealth()
|
||||
return nil
|
||||
}
|
||||
if let lastPosition, sample.position < lastPosition - 0.1 {
|
||||
resetHealth()
|
||||
}
|
||||
if lastPosition == nil || sample.position > lastPosition! + 0.01 {
|
||||
lastPosition = sample.position
|
||||
lastProgress = now
|
||||
}
|
||||
let stalled = sample.buffering || now - (lastProgress ?? now) >= 3
|
||||
let ahead = sample.ahead.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
|
||||
// Live packet timestamps are not a reliable reserve estimate.
|
||||
let knownReserve = !sample.live && ahead != nil
|
||||
if stalled {
|
||||
stallSince = stallSince ?? now
|
||||
} else {
|
||||
stallSince = nil
|
||||
}
|
||||
if knownReserve && ahead! < 4 {
|
||||
lowSince = lowSince ?? now
|
||||
} else {
|
||||
lowSince = nil
|
||||
}
|
||||
if !stalled && (!knownReserve || ahead! >= 20) {
|
||||
healthySince = healthySince ?? now
|
||||
} else {
|
||||
healthySince = nil
|
||||
}
|
||||
guard now >= notBefore else { return nil }
|
||||
let downshift = stallSince.map({ now - $0 >= 3 }) == true || lowSince.map({ now - $0 >= 6 }) == true
|
||||
let upshift = healthySince.map({ now - $0 >= (knownReserve ? 60 : 120) }) == true
|
||||
guard height > 1080 || downshift || upshift else { return nil }
|
||||
var target: Int?
|
||||
for available in sample.heights where available > 0 && available <= 1080 {
|
||||
if height > 1080 || downshift {
|
||||
if available < height && available > (target ?? 0) { target = available }
|
||||
} else if available > height && available < (target ?? Int.max) {
|
||||
target = available
|
||||
}
|
||||
}
|
||||
guard let target else { return nil }
|
||||
notBefore = now + 30
|
||||
resetHealth()
|
||||
return target
|
||||
}
|
||||
}
|
||||
+127
-31
@@ -16,6 +16,7 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var youtubeHeights: [Int] = []
|
||||
@Published private(set) var youtubeHeight: Int?
|
||||
@Published private(set) var youtubeQuality: Int?
|
||||
@Published private(set) var automaticYouTubeQuality = false
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -48,6 +49,11 @@ final class PlayerModel: ObservableObject {
|
||||
private var seekPending = false
|
||||
private var youtubeTask: Task<Void, Never>?
|
||||
private var youtubeRequest: UUID?
|
||||
private var resolvingAutomaticQuality = false
|
||||
private let defaults: UserDefaults
|
||||
private let qualityClock: () -> Double
|
||||
private var automaticQuality = AutomaticQuality()
|
||||
private var qualityNow: Double { qualityClock() }
|
||||
private var playbackProgress: PlaybackProgress
|
||||
private var progressURL: URL?
|
||||
private var resumePosition: Double?
|
||||
@@ -55,7 +61,10 @@ final class PlayerModel: ObservableObject {
|
||||
private var lastSavedPosition: Double?
|
||||
private var systemPlayback: SystemPlaybackControls?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
init(defaults: UserDefaults = .standard, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.defaults = defaults
|
||||
self.qualityClock = qualityClock
|
||||
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
|
||||
playbackProgress = PlaybackProgress(defaults: defaults)
|
||||
let player = player_create()
|
||||
handle = player
|
||||
@@ -74,7 +83,7 @@ final class PlayerModel: ObservableObject {
|
||||
systemPlayback = SystemPlaybackControls(model: self)
|
||||
}
|
||||
|
||||
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
|
||||
var isBusy: Bool { (resolvingYouTube && !resolvingAutomaticQuality) || pendingRevision != nil || state.status == PLAYER_OPENING }
|
||||
var canTogglePlayback: Bool {
|
||||
handle != nil && !isBusy &&
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
||||
@@ -149,9 +158,20 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: nil)
|
||||
}
|
||||
|
||||
func setAutomaticYouTubeQuality(_ enabled: Bool) {
|
||||
automaticYouTubeQuality = enabled
|
||||
defaults.set(enabled, forKey: "automaticYouTubeQuality")
|
||||
automaticQuality = AutomaticQuality()
|
||||
if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
revealControls()
|
||||
}
|
||||
|
||||
func setYouTubeQuality(_ height: Int?) {
|
||||
// A manual choice is an override, including reselecting the active/default resolution.
|
||||
setAutomaticYouTubeQuality(false)
|
||||
guard canChangeYouTubeQuality, let url = progressURL, let handle,
|
||||
height != youtubeQuality, height == nil || youtubeHeights.contains(height!) else { return }
|
||||
height == nil || youtubeHeights.contains(height!) else { return }
|
||||
guard height != youtubeQuality else { return }
|
||||
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
|
||||
if target == youtubeHeight {
|
||||
youtubeQuality = height
|
||||
@@ -162,10 +182,12 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
|
||||
}
|
||||
|
||||
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil) {
|
||||
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
|
||||
automatic: Bool = false) {
|
||||
guard let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
if !player_pause(handle) {
|
||||
if !automatic { automaticQuality = AutomaticQuality() }
|
||||
if !automatic && !player_pause(handle) {
|
||||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||||
revealControls()
|
||||
return
|
||||
@@ -173,6 +195,7 @@ final class PlayerModel: ObservableObject {
|
||||
let request = UUID()
|
||||
youtubeRequest = request
|
||||
resolvingYouTube = true
|
||||
resolvingAutomaticQuality = automatic
|
||||
systemPlayback?.update()
|
||||
commandError = nil
|
||||
revealControls()
|
||||
@@ -180,20 +203,36 @@ final class PlayerModel: ObservableObject {
|
||||
do {
|
||||
let video = try await YouTubeResolver.resolve(url, height: height)
|
||||
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
|
||||
self.youtubeTask = nil
|
||||
self.youtubeRequest = nil
|
||||
self.resolvingYouTube = false
|
||||
self.openResolvedYouTube(video, height: height, restoring: restoring)
|
||||
if automatic {
|
||||
let current = player_get_state(handle)
|
||||
guard self.automaticYouTubeQuality, current.status == PLAYER_PLAYING,
|
||||
!self.isSeeking, !self.seekPending, !current.seek_pending,
|
||||
self.pendingRevision == nil,
|
||||
self.isLiveStream || !current.duration.isFinite || current.duration <= 0 ||
|
||||
current.duration - current.position > 15 else {
|
||||
self.cancelYouTubeResolution()
|
||||
self.automaticQuality.resetHealth()
|
||||
return
|
||||
}
|
||||
let position = !self.isLiveStream && current.can_seek ? current.position : nil
|
||||
self.cancelYouTubeResolution()
|
||||
self.openResolvedYouTube(video, height: height, restoring: (position, true), automatic: true)
|
||||
} else {
|
||||
self.cancelYouTubeResolution()
|
||||
self.openResolvedYouTube(video, height: height, restoring: restoring)
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
|
||||
self.youtubeTask = nil
|
||||
self.youtubeRequest = nil
|
||||
self.resolvingYouTube = false
|
||||
self.commandError = error.localizedDescription
|
||||
if restoring?.playing == true, let handle = self.handle, !player_play(handle) {
|
||||
self.commandError = error.localizedDescription + "\nCould not resume the previous playback."
|
||||
self.cancelYouTubeResolution()
|
||||
if automatic {
|
||||
self.automaticQuality.extractionFailed(at: self.qualityNow)
|
||||
} else {
|
||||
self.commandError = error.localizedDescription
|
||||
if restoring?.playing == true, let handle = self.handle, !player_play(handle) {
|
||||
self.commandError = error.localizedDescription + "\nCould not resume the previous playback."
|
||||
}
|
||||
self.revealControls()
|
||||
}
|
||||
self.revealControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,9 +242,11 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeTask?.cancel()
|
||||
youtubeTask = nil
|
||||
resolvingYouTube = false
|
||||
resolvingAutomaticQuality = false
|
||||
}
|
||||
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?) {
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?,
|
||||
automatic: Bool = false) {
|
||||
guard let handle else { return }
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
@@ -226,6 +267,10 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
guard accepted else {
|
||||
if automatic {
|
||||
automaticQuality.extractionFailed(at: qualityNow)
|
||||
return
|
||||
}
|
||||
commandError = "Could not queue the YouTube video to open. Please try again."
|
||||
if restoring?.playing == true, !player_play(handle) {
|
||||
commandError = "Could not queue the YouTube video or resume the previous playback. Please try again."
|
||||
@@ -234,15 +279,16 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL,
|
||||
live: video.isLive, restoring: restoring)
|
||||
live: video.isLive, restoring: restoring, automatic: automatic)
|
||||
youtubeHeights = video.availableHeights
|
||||
youtubeHeight = video.height
|
||||
youtubeQuality = height
|
||||
}
|
||||
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
|
||||
restoring: (position: Double?, playing: Bool)? = nil) {
|
||||
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
|
||||
guard let handle else { return }
|
||||
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||
pendingRevision = revision
|
||||
fileName = title
|
||||
fileIcon = icon
|
||||
@@ -299,6 +345,8 @@ final class PlayerModel: ObservableObject {
|
||||
@discardableResult
|
||||
func setPlayback(playing: Bool) -> Bool {
|
||||
guard let handle, canTogglePlayback else { return false }
|
||||
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if !playing { automaticQuality.resetHealth() }
|
||||
let accepted = playing ? player_play(handle) : player_pause(handle)
|
||||
commandError = accepted ? nil : "Could not queue playback. Please try again."
|
||||
revealControls()
|
||||
@@ -306,6 +354,8 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func finishSeeking() {
|
||||
if resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
isSeeking = false
|
||||
guard let handle, canSeek else { return }
|
||||
let position = min(max(0, seekPosition), duration)
|
||||
@@ -319,6 +369,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard let handle, canClose else { return }
|
||||
saveProgress()
|
||||
cancelYouTubeResolution()
|
||||
automaticQuality = AutomaticQuality()
|
||||
guard player_close(handle) else {
|
||||
commandError = "Could not queue closing the file. Please try again."
|
||||
return
|
||||
@@ -348,6 +399,8 @@ final class PlayerModel: ObservableObject {
|
||||
@discardableResult
|
||||
func seekRelative(_ seconds: Double) -> Bool {
|
||||
guard canSeek, let handle, seconds.isFinite else { return false }
|
||||
if resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
|
||||
let accepted = player_seek(handle, target)
|
||||
if accepted {
|
||||
@@ -452,6 +505,8 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func beginSeeking() {
|
||||
if resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
isSeeking = true
|
||||
revealControls()
|
||||
}
|
||||
@@ -543,6 +598,7 @@ final class PlayerModel: ObservableObject {
|
||||
if statusChanged { revealControls() }
|
||||
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
|
||||
saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED))
|
||||
updateAutomaticQuality()
|
||||
if let saved = resumePosition {
|
||||
resumePosition = nil
|
||||
guard let handle, next.status == PLAYER_PAUSED || next.status == PLAYER_PLAYING else { return }
|
||||
@@ -564,6 +620,19 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAutomaticQuality() {
|
||||
guard automaticYouTubeQuality else { return }
|
||||
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
|
||||
let sample = AutomaticQuality.Sample(
|
||||
enabled: automaticYouTubeQuality, network: isNetworkMedia,
|
||||
playing: state.status == PLAYER_PLAYING, opening: isBusy || resolvingYouTube || resumePosition != nil,
|
||||
seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream,
|
||||
buffering: state.buffering, position: state.position, duration: state.duration,
|
||||
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
|
||||
guard let height = automaticQuality.choose(sample, at: qualityNow), let url = progressURL else { return }
|
||||
resolveYouTube(url, height: height, automatic: true)
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
saveProgress()
|
||||
cancelYouTubeResolution()
|
||||
@@ -756,15 +825,28 @@ private struct PlayerContent: View {
|
||||
private var controlHeight: CGFloat { model.isNetworkMedia ? 88 : 68 }
|
||||
private var compactControlHeight: CGFloat { model.isNetworkMedia ? 52 : 40 }
|
||||
|
||||
private var bufferedStatus: String? {
|
||||
model.bufferedRange.map {
|
||||
let behind = max(0, model.state.position - $0.lowerBound)
|
||||
let ahead = max(0, $0.upperBound - model.state.position)
|
||||
return "Reusable buffer: \(String(format: "%.1f", behind)) seconds behind, " +
|
||||
"\(String(format: "%.1f", ahead)) seconds ahead; buffered through \(time($0.upperBound))."
|
||||
}
|
||||
}
|
||||
|
||||
private var bufferReadout: some View {
|
||||
Text(model.bufferedRange.map {
|
||||
"Buffered: \(String(format: "%.1f", $0.upperBound - $0.lowerBound))s ahead"
|
||||
} ?? "Buffered: —")
|
||||
let behind = max(0, model.state.position - $0.lowerBound)
|
||||
let ahead = max(0, $0.upperBound - model.state.position)
|
||||
return (model.isCompact ? "" : "Buffer: ") + String(format: "−%.0fs +%.0fs", behind, ahead)
|
||||
} ?? (model.isCompact ? "Buffer —" : "Buffer: —"))
|
||||
.font(.system(size: 10).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
.lineLimit(1)
|
||||
.frame(height: 12)
|
||||
.help("Conservative playable buffer ahead; — means the estimate is unavailable.")
|
||||
.help(bufferedStatus ?? "Reusable buffer unavailable.")
|
||||
.accessibilityLabel("Reusable Buffer")
|
||||
.accessibilityValue(bufferedStatus ?? "Unavailable")
|
||||
}
|
||||
|
||||
private func time(_ seconds: Double) -> String {
|
||||
@@ -820,15 +902,32 @@ private struct PlayerContent: View {
|
||||
Button("Close File", action: model.closeFile).disabled(!model.canClose)
|
||||
if !model.youtubeHeights.isEmpty {
|
||||
Divider()
|
||||
Picker("Quality", selection: Binding(
|
||||
get: { model.youtubeQuality ?? 0 },
|
||||
set: { model.setYouTubeQuality($0 == 0 ? nil : $0) })) {
|
||||
Text("Default (up to 1080p)").tag(0)
|
||||
Menu("Quality") {
|
||||
Toggle("Automatic Quality (up to 1080p)", isOn: Binding(
|
||||
get: { model.automaticYouTubeQuality },
|
||||
set: { model.setAutomaticYouTubeQuality($0) }))
|
||||
Divider()
|
||||
Button { model.setYouTubeQuality(nil) } label: {
|
||||
HStack {
|
||||
Text("Default (up to 1080p)")
|
||||
if !model.automaticYouTubeQuality && model.youtubeQuality == nil {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeYouTubeQuality)
|
||||
ForEach(model.youtubeHeights, id: \.self) { height in
|
||||
Text("\(height)p").tag(height)
|
||||
Button { model.setYouTubeQuality(height) } label: {
|
||||
HStack {
|
||||
Text("\(height)p")
|
||||
if !model.automaticYouTubeQuality && model.youtubeQuality == height {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeYouTubeQuality)
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeYouTubeQuality)
|
||||
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
||||
}
|
||||
Divider()
|
||||
@@ -874,9 +973,6 @@ private struct PlayerContent: View {
|
||||
|
||||
private var playbackControls: some View {
|
||||
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) {
|
||||
|
||||
+299
-93
@@ -11,27 +11,61 @@ extern "C" {
|
||||
#include <cerrno>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <iterator>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
namespace playback {
|
||||
class NetworkInput;
|
||||
|
||||
// One budget per player, shared by combined or separate compressed inputs.
|
||||
class StreamCacheBudget {
|
||||
public:
|
||||
static constexpr std::size_t byteLimit = 128 * 1024 * 1024;
|
||||
explicit StreamCacheBudget(std::size_t limit = byteLimit)
|
||||
: limit(std::min(limit, byteLimit)) {
|
||||
if (!this->limit) throw std::invalid_argument("The streaming cache budget must be positive");
|
||||
}
|
||||
std::size_t bytes() const {
|
||||
std::lock_guard lock(mutex);
|
||||
return used;
|
||||
}
|
||||
private:
|
||||
friend class NetworkInput;
|
||||
const std::size_t limit;
|
||||
std::size_t used = 0;
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable changed;
|
||||
std::array<NetworkInput *, 2> inputs{};
|
||||
};
|
||||
|
||||
// Only network inputs need a reader: a blocked socket must never own the player worker.
|
||||
// Format metadata is immutable after start(); the reader exclusively owns demux and seek I/O.
|
||||
class NetworkInput {
|
||||
public:
|
||||
static constexpr std::size_t packetLimit = 64;
|
||||
static constexpr std::size_t byteLimit = 16 * 1024 * 1024;
|
||||
static constexpr double aheadSeconds = 60, behindSeconds = 30;
|
||||
AVFormatContext *format = nullptr;
|
||||
|
||||
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
|
||||
uint64_t activeEpoch, std::condition_variable &playerWake)
|
||||
: stopping(stopping), epoch(epoch), activeEpoch(activeEpoch), playerWake(playerWake) {}
|
||||
uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget)
|
||||
: stopping(stopping), epoch(epoch), activeEpoch(activeEpoch), playerWake(playerWake), budget(budget) {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
auto free = std::find(budget.inputs.begin(), budget.inputs.end(), nullptr);
|
||||
if (free == budget.inputs.end()) throw std::invalid_argument("Too many streaming inputs");
|
||||
*free = this;
|
||||
}
|
||||
~NetworkInput() {
|
||||
stop();
|
||||
for (auto &lane : lanes)
|
||||
for (auto *packet : lane.packets) av_packet_free(&packet);
|
||||
{
|
||||
std::lock_guard lock(budget.mutex);
|
||||
for (auto &lane : lanes) clear(lane);
|
||||
for (auto &input : budget.inputs) if (input == this) input = nullptr;
|
||||
budget.changed.notify_all();
|
||||
}
|
||||
av_packet_free(&scratch);
|
||||
avformat_close_input(&format);
|
||||
}
|
||||
@@ -50,14 +84,25 @@ 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, double timelineOrigin) {
|
||||
origin = timelineOrigin;
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
lanes[slot].index = indices[slot];
|
||||
if (indices[slot] < 0) continue;
|
||||
for (auto &packet : lanes[slot].packets) {
|
||||
packet = av_packet_alloc();
|
||||
if (!packet) throw std::bad_alloc();
|
||||
void start(std::array<int, 2> indices, double timelineOrigin, bool allowCachedSeeking) {
|
||||
{
|
||||
std::lock_guard lock(budget.mutex);
|
||||
origin = timelineOrigin;
|
||||
cacheSeeking = allowCachedSeeking;
|
||||
started = true;
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
auto &lane = lanes[slot];
|
||||
lane.index = indices[slot];
|
||||
if (lane.index < 0) continue;
|
||||
const auto *stream = format->streams[lane.index];
|
||||
lane.tick = av_q2d(stream->time_base);
|
||||
lane.mediaStart = stream->start_time == AV_NOPTS_VALUE ? 0 :
|
||||
stream->start_time * lane.tick - origin;
|
||||
if (stream->duration != AV_NOPTS_VALUE && stream->duration > 0)
|
||||
lane.mediaEnd = lane.mediaStart + stream->duration * lane.tick;
|
||||
if (slot == 0 && stream->codecpar->sample_rate > 0)
|
||||
lane.preroll = std::max(stream->codecpar->seek_preroll, 0) /
|
||||
static_cast<double>(stream->codecpar->sample_rate);
|
||||
}
|
||||
}
|
||||
scratch = av_packet_alloc();
|
||||
@@ -66,109 +111,172 @@ public:
|
||||
}
|
||||
void stop() {
|
||||
cancelled.store(true, std::memory_order_relaxed);
|
||||
changed.notify_all();
|
||||
budget.changed.notify_all();
|
||||
if (reader.joinable()) reader.join();
|
||||
}
|
||||
void updatePosition(double seconds) {
|
||||
if (!std::isfinite(seconds)) return;
|
||||
std::lock_guard lock(budget.mutex);
|
||||
position = std::max(0.0, seconds);
|
||||
for (int slot = 0; slot < 2; ++slot) trim(slot);
|
||||
budget.changed.notify_all();
|
||||
}
|
||||
void seek(int streamIndex, int64_t timestamp) {
|
||||
std::lock_guard lock(mutex);
|
||||
std::lock_guard lock(budget.mutex);
|
||||
const double target = (streamIndex < 0 ? static_cast<double>(timestamp) / AV_TIME_BASE :
|
||||
static_cast<double>(timestamp) * av_q2d(format->streams[streamIndex]->time_base)) - origin;
|
||||
std::array<uint64_t, 2> cursors{};
|
||||
bool cached = cacheSeeking && !seeking && !failure && std::isfinite(target);
|
||||
for (int slot = 0; slot < 2 && cached; ++slot) {
|
||||
const auto &lane = lanes[slot];
|
||||
if (lane.index < 0 || (slot == 0 && skipAudio)) continue;
|
||||
// A forced finish may have interrupted demux. Only a completely cached short
|
||||
// track can resume without restoring its I/O position through a real seek.
|
||||
if (halted && !ended && !(std::isfinite(lane.mediaEnd) &&
|
||||
lane.frontierEnd + lane.tick >= lane.mediaEnd)) cached = false;
|
||||
else cached = cachedCursor(slot, target, cursors[slot]);
|
||||
}
|
||||
position = std::max(0.0, target);
|
||||
if (cached) {
|
||||
for (int slot = 0; slot < 2; ++slot)
|
||||
if (lanes[slot].index >= 0 && !(slot == 0 && skipAudio)) lanes[slot].cursor = cursors[slot];
|
||||
// Cache hits never interrupt the outstanding read at the fetch frontier.
|
||||
halted = false;
|
||||
budget.changed.notify_all();
|
||||
playerWake.notify_one();
|
||||
return;
|
||||
}
|
||||
for (auto &lane : lanes) clear(lane);
|
||||
scratchSlot = -1;
|
||||
ended = false;
|
||||
audioDropped = false;
|
||||
ended = halted = false;
|
||||
failure = 0;
|
||||
seekIndex = streamIndex;
|
||||
seekTimestamp = timestamp;
|
||||
seeking = true;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
changed.notify_all();
|
||||
budget.changed.notify_all();
|
||||
}
|
||||
void discardAudio(bool discard) {
|
||||
std::lock_guard lock(mutex);
|
||||
std::lock_guard lock(budget.mutex);
|
||||
skipAudio = discard;
|
||||
if (discard) clear(lanes[0]);
|
||||
if (discard && scratchSlot == 0) scratchSlot = -1;
|
||||
changed.notify_all();
|
||||
// Stepping must not leave an unread audio lane blocking a combined demuxer.
|
||||
if (discard) {
|
||||
lanes[0].cursor = tail(lanes[0]);
|
||||
trim(0);
|
||||
}
|
||||
budget.changed.notify_all();
|
||||
}
|
||||
void finish() {
|
||||
std::lock_guard lock(mutex);
|
||||
for (auto &lane : lanes) clear(lane);
|
||||
scratchSlot = -1;
|
||||
ended = true;
|
||||
std::lock_guard lock(budget.mutex);
|
||||
for (auto &lane : lanes) lane.cursor = tail(lane);
|
||||
halted = true;
|
||||
seeking = false;
|
||||
failure = 0;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
changed.notify_all();
|
||||
budget.changed.notify_all();
|
||||
}
|
||||
bool seekPending() const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::lock_guard lock(budget.mutex);
|
||||
return seeking;
|
||||
}
|
||||
int error() const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::lock_guard lock(budget.mutex);
|
||||
return failure;
|
||||
}
|
||||
double bufferedEnd(int slot) const {
|
||||
std::lock_guard lock(mutex);
|
||||
if (failure || seeking) return 0;
|
||||
double bufferedStart(int slot) const {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
if (!cacheSeeking || failure || seeking || (slot == 0 && skipAudio)) return NAN;
|
||||
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);
|
||||
if (lane.points.empty() || (slot == 0 && audioDropped && !trackEnded(lane))) return NAN;
|
||||
if (slot == 1 && (lane.packets.empty() || position > lane.packets.back().end + lane.tick)) return NAN;
|
||||
const double start = lane.points.front().time + lane.preroll;
|
||||
return std::max(0.0, lane.atBeginning ? std::min(start, lane.mediaStart) : start);
|
||||
}
|
||||
// 1: packet, 0: not yet available, AVERROR_EOF: this input is drained.
|
||||
double bufferedEnd(int slot) const {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
if (failure || seeking || (slot == 0 && skipAudio)) return 0;
|
||||
const auto &lane = lanes[slot];
|
||||
return lane.packets.empty() ? 0 : lane.packets.back().end;
|
||||
}
|
||||
// 1: packet, 0: not yet available, AVERROR_EOF: this lane is drained.
|
||||
int take(int slot, AVPacket *destination, double &bufferedEnd) {
|
||||
std::lock_guard lock(mutex);
|
||||
std::lock_guard lock(budget.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;
|
||||
--lane.count;
|
||||
changed.notify_one();
|
||||
if (lane.cursor < tail(lane)) {
|
||||
auto &packet = lane.packets[lane.cursor - lane.first];
|
||||
const int result = av_packet_ref(destination, &packet.packet);
|
||||
if (result < 0) return result;
|
||||
bufferedEnd = packet.end;
|
||||
++lane.cursor;
|
||||
trim(slot);
|
||||
budget.changed.notify_all();
|
||||
return 1;
|
||||
}
|
||||
return ended ? AVERROR_EOF : 0;
|
||||
return ended || halted || trackEnded(lane) ? AVERROR_EOF : 0;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Packet {
|
||||
AVPacket packet{};
|
||||
double end = 0;
|
||||
std::size_t bytes = 0;
|
||||
~Packet() { av_packet_unref(&packet); }
|
||||
Packet() = default;
|
||||
Packet(const Packet &) = delete;
|
||||
Packet &operator=(const Packet &) = delete;
|
||||
};
|
||||
struct SeekPoint { double time; uint64_t sequence; };
|
||||
struct Lane {
|
||||
std::array<AVPacket *, packetLimit> packets{};
|
||||
std::array<double, packetLimit> ends{};
|
||||
std::deque<Packet> packets;
|
||||
std::deque<SeekPoint> points;
|
||||
int index = -1;
|
||||
std::size_t first = 0, count = 0, bytes = 0;
|
||||
uint64_t first = 0, cursor = 0;
|
||||
std::size_t bytes = 0;
|
||||
double tick = 0, frontierEnd = 0, mediaStart = 0, mediaEnd = NAN, preroll = 0;
|
||||
bool atBeginning = false;
|
||||
};
|
||||
const std::atomic<bool> &stopping;
|
||||
const std::atomic<uint64_t> &epoch;
|
||||
const uint64_t activeEpoch;
|
||||
std::condition_variable &playerWake;
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable changed;
|
||||
StreamCacheBudget &budget;
|
||||
std::array<Lane, 2> lanes;
|
||||
AVPacket *scratch = nullptr;
|
||||
std::atomic<bool> cancelled{false};
|
||||
std::atomic<uint64_t> serial{0}, operationSerial{0};
|
||||
std::atomic<int64_t> deadline{std::numeric_limits<int64_t>::max()};
|
||||
bool seeking = false, ended = false, skipAudio = false;
|
||||
bool seeking = false, ended = false, halted = false, skipAudio = false;
|
||||
bool cacheSeeking = false, started = false, audioDropped = false;
|
||||
int seekIndex = -1, failure = 0;
|
||||
int64_t seekTimestamp = 0;
|
||||
int scratchSlot = -1;
|
||||
double scratchEnd = 0, origin = 0;
|
||||
double origin = 0, position = 0;
|
||||
std::thread reader;
|
||||
|
||||
static int64_t now() {
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
static std::size_t packetBytes(const AVPacket *packet) {
|
||||
if (packet->size < 0) return byteLimit + 1;
|
||||
std::size_t bytes = static_cast<std::size_t>(packet->size);
|
||||
if (bytes > byteLimit) return byteLimit + 1;
|
||||
static uint64_t tail(const Lane &lane) { return lane.first + lane.packets.size(); }
|
||||
static bool trackEnded(const Lane &lane) {
|
||||
return std::isfinite(lane.mediaEnd) && lane.frontierEnd + lane.tick * 0.5 >= lane.mediaEnd;
|
||||
}
|
||||
static double packetTime(const Lane &lane, const AVPacket *packet) {
|
||||
const int64_t timestamp = packet->pts != AV_NOPTS_VALUE ? packet->pts : packet->dts;
|
||||
return timestamp == AV_NOPTS_VALUE ? NAN : static_cast<double>(timestamp) * lane.tick;
|
||||
}
|
||||
std::size_t packetBytes(const AVPacket *packet) const {
|
||||
if (packet->size < 0) return budget.limit + 1;
|
||||
std::size_t bytes = sizeof(Packet) + sizeof(SeekPoint);
|
||||
const auto payload = packet->buf ? packet->buf->size : static_cast<std::size_t>(packet->size);
|
||||
if (payload > budget.limit || bytes > budget.limit - payload) return budget.limit + 1;
|
||||
bytes += payload;
|
||||
for (int i = 0; i < packet->side_data_elems; ++i) {
|
||||
if (packet->side_data[i].size > byteLimit - bytes) return byteLimit + 1;
|
||||
bytes += packet->side_data[i].size;
|
||||
const auto size = packet->side_data[i].size;
|
||||
if (size > budget.limit || sizeof(AVPacketSideData) > budget.limit - size ||
|
||||
bytes > budget.limit - size - sizeof(AVPacketSideData)) return budget.limit + 1;
|
||||
bytes += size + sizeof(AVPacketSideData);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -176,23 +284,102 @@ private:
|
||||
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.
|
||||
// PTS can jump to a future reference frame before its B-frames arrive.
|
||||
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 tick = lanes[slot].tick;
|
||||
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]);
|
||||
lane.first = (lane.first + 1) % packetLimit;
|
||||
--lane.count;
|
||||
void pop(Lane &lane) {
|
||||
const auto bytes = lane.packets.front().bytes;
|
||||
budget.used -= bytes;
|
||||
lane.bytes -= bytes;
|
||||
lane.packets.pop_front();
|
||||
++lane.first;
|
||||
lane.atBeginning = false;
|
||||
while (!lane.points.empty() && lane.points.front().sequence < lane.first) lane.points.pop_front();
|
||||
}
|
||||
void clear(Lane &lane) {
|
||||
budget.used -= lane.bytes;
|
||||
lane.packets.clear();
|
||||
lane.points.clear();
|
||||
lane.first = lane.cursor = 0;
|
||||
lane.bytes = 0;
|
||||
lane.frontierEnd = 0;
|
||||
lane.atBeginning = false;
|
||||
}
|
||||
static auto pointAt(const Lane &lane, double seconds) {
|
||||
return std::upper_bound(lane.points.begin(), lane.points.end(), seconds,
|
||||
[](double value, const SeekPoint &point) { return value < point.time; });
|
||||
}
|
||||
bool cachedCursor(int slot, double target, uint64_t &cursor) const {
|
||||
const auto &lane = lanes[slot];
|
||||
if (slot == 0 && std::isfinite(lane.mediaEnd) && target + lane.tick >= lane.mediaEnd) {
|
||||
cursor = tail(lane);
|
||||
return true;
|
||||
}
|
||||
lane.first = lane.bytes = 0;
|
||||
if (slot == 0 && audioDropped && !trackEnded(lane)) return false;
|
||||
if (lane.points.empty() || lane.packets.empty() ||
|
||||
target > lane.packets.back().end + lane.tick) return false;
|
||||
const auto after = pointAt(lane, target - lane.preroll);
|
||||
if (after == lane.points.begin()) {
|
||||
if (!lane.atBeginning) return false;
|
||||
cursor = lane.points.front().sequence;
|
||||
} else cursor = std::prev(after)->sequence;
|
||||
return true;
|
||||
}
|
||||
void trim(int slot) {
|
||||
auto &lane = lanes[slot];
|
||||
uint64_t keep = lane.cursor;
|
||||
if (cacheSeeking) {
|
||||
const auto after = pointAt(lane, position - behindSeconds - lane.preroll);
|
||||
if (after == lane.points.begin()) return;
|
||||
keep = std::min(keep, std::prev(after)->sequence);
|
||||
}
|
||||
while (!lane.packets.empty() && lane.first < keep) pop(lane);
|
||||
}
|
||||
std::size_t laneLimit() const {
|
||||
unsigned tracks = 0;
|
||||
for (const auto *input : budget.inputs) {
|
||||
if (!input) continue;
|
||||
if (!input->started) { ++tracks; continue; }
|
||||
for (int slot = 0; slot < 2; ++slot)
|
||||
if (input->lanes[slot].index >= 0 && !(slot == 0 && input->skipAudio)) ++tracks;
|
||||
}
|
||||
// Each required track gets a reserved eighth; spare capacity remains borrowable.
|
||||
return tracks > 1 ? budget.limit - budget.limit / 8 : budget.limit;
|
||||
}
|
||||
bool makeRoom(Lane &lane, std::size_t bytes) {
|
||||
const auto quota = std::max(laneLimit(), bytes);
|
||||
while (budget.used > budget.limit - bytes || lane.bytes > quota - bytes) {
|
||||
Lane *oldest = nullptr;
|
||||
double age = -std::numeric_limits<double>::infinity();
|
||||
for (auto *input : budget.inputs) {
|
||||
if (!input) continue;
|
||||
for (auto &candidate : input->lanes) {
|
||||
if (candidate.packets.empty() || candidate.first >= candidate.cursor) continue;
|
||||
if (lane.bytes > quota - bytes && &candidate != &lane) continue;
|
||||
const double distance = input->position - candidate.packets.front().end;
|
||||
if (!oldest || distance > age) { oldest = &candidate; age = distance; }
|
||||
}
|
||||
}
|
||||
if (!oldest) return false;
|
||||
pop(*oldest);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool needsRead() const {
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
const auto &lane = lanes[slot];
|
||||
if (lane.index < 0 || (slot == 0 && skipAudio)) continue;
|
||||
const double goal = std::isfinite(lane.mediaEnd) ?
|
||||
std::min(position + aheadSeconds, lane.mediaEnd) : position + aheadSeconds;
|
||||
if (lane.frontierEnd + lane.tick < goal) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool stoppingNow() const {
|
||||
return cancelled.load(std::memory_order_relaxed) ||
|
||||
@@ -206,12 +393,12 @@ private:
|
||||
int64_t timestamp;
|
||||
bool doSeek;
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
|
||||
return stoppingNow() || seeking || !ended;
|
||||
std::unique_lock lock(budget.mutex);
|
||||
budget.changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
|
||||
return stoppingNow() || seeking || (!ended && !halted && needsRead());
|
||||
});
|
||||
if (stoppingNow()) return;
|
||||
if (ended && !seeking) continue;
|
||||
if (!seeking && (ended || halted || !needsRead())) continue;
|
||||
current = serial.load(std::memory_order_acquire);
|
||||
doSeek = seeking;
|
||||
index = seekIndex;
|
||||
@@ -222,13 +409,14 @@ private:
|
||||
if (doSeek) {
|
||||
const int result = index >= 0 ?
|
||||
av_seek_frame(format, index, timestamp, AVSEEK_FLAG_BACKWARD) :
|
||||
avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(), timestamp, timestamp, 0);
|
||||
avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(),
|
||||
timestamp, timestamp, AVSEEK_FLAG_BACKWARD);
|
||||
if (result >= 0 && format->pb) {
|
||||
// An interrupted read leaves AVIO's error sticky even after a successful format seek.
|
||||
// Interrupted reads leave AVIO's error sticky after a successful seek.
|
||||
format->pb->error = 0;
|
||||
format->pb->eof_reached = 0;
|
||||
}
|
||||
std::lock_guard lock(mutex);
|
||||
std::lock_guard lock(budget.mutex);
|
||||
if (serial.load(std::memory_order_acquire) != current) continue;
|
||||
seeking = false;
|
||||
if (result < 0) { failure = result; ended = true; }
|
||||
@@ -236,14 +424,14 @@ private:
|
||||
if (result < 0) continue;
|
||||
}
|
||||
const int result = av_read_frame(format, scratch);
|
||||
std::unique_lock lock(mutex);
|
||||
std::unique_lock lock(budget.mutex);
|
||||
if (stoppingNow()) return;
|
||||
if (serial.load(std::memory_order_acquire) != current) {
|
||||
av_packet_unref(scratch);
|
||||
continue;
|
||||
}
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
changed.wait_for(lock, std::chrono::milliseconds(5));
|
||||
budget.changed.wait_for(lock, std::chrono::milliseconds(5));
|
||||
continue;
|
||||
}
|
||||
if (result < 0) {
|
||||
@@ -256,38 +444,56 @@ private:
|
||||
int slot = -1;
|
||||
for (int i = 0; i < 2; ++i)
|
||||
if (lanes[i].index >= 0 && scratch->stream_index == lanes[i].index) slot = i;
|
||||
if (slot < 0 || (slot == 0 && skipAudio)) { av_packet_unref(scratch); continue; }
|
||||
if (slot < 0 || (slot == 0 && skipAudio)) {
|
||||
if (slot == 0) audioDropped = true;
|
||||
av_packet_unref(scratch);
|
||||
continue;
|
||||
}
|
||||
auto &lane = lanes[slot];
|
||||
const auto bytes = packetBytes(scratch);
|
||||
if (bytes > byteLimit) {
|
||||
failure = AVERROR(ENOBUFS);
|
||||
if (bytes > budget.limit) {
|
||||
failure = scratch->size < 0 ? AVERROR_INVALIDDATA : AVERROR(ENOBUFS);
|
||||
ended = true;
|
||||
av_packet_unref(scratch);
|
||||
playerWake.notify_one();
|
||||
continue;
|
||||
}
|
||||
scratchSlot = slot;
|
||||
scratchEnd = packetEnd(slot, scratch);
|
||||
changed.wait(lock, [&] {
|
||||
// The demuxer's single in-flight packet is not retained until it fits.
|
||||
// Byte pressure shortens consumed history, never drops unread packets.
|
||||
budget.changed.wait(lock, [&] {
|
||||
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
|
||||
(slot == 0 && skipAudio) ||
|
||||
(lane.count < packetLimit && lane.bytes + bytes <= byteLimit);
|
||||
(slot == 0 && skipAudio) || makeRoom(lane, bytes);
|
||||
});
|
||||
if (stoppingNow()) return;
|
||||
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
|
||||
if (slot == 0 && skipAudio) audioDropped = true;
|
||||
av_packet_unref(scratch);
|
||||
scratchSlot = -1;
|
||||
continue;
|
||||
}
|
||||
const double time = packetTime(lane, scratch) - origin;
|
||||
const double end = std::max(lane.frontierEnd, packetEnd(slot, scratch));
|
||||
const uint64_t sequence = tail(lane);
|
||||
lane.packets.emplace_back();
|
||||
auto &packet = lane.packets.back();
|
||||
packet.bytes = bytes;
|
||||
packet.end = end;
|
||||
av_packet_move_ref(&packet.packet, scratch);
|
||||
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;
|
||||
budget.used += bytes;
|
||||
lane.frontierEnd = end;
|
||||
const bool seekPoint = !(packet.packet.flags & AV_PKT_FLAG_DISCARD) &&
|
||||
(slot == 0 || (packet.packet.flags & AV_PKT_FLAG_KEY));
|
||||
if (seekPoint && std::isfinite(time) &&
|
||||
(lane.points.empty() || time >= lane.points.back().time)) {
|
||||
lane.points.push_back({time, sequence});
|
||||
if (lane.points.size() == 1 && sequence == lane.first &&
|
||||
time <= lane.mediaStart + lane.tick) lane.atBeginning = true;
|
||||
}
|
||||
trim(slot);
|
||||
playerWake.notify_one();
|
||||
}
|
||||
} catch (...) {
|
||||
std::lock_guard lock(mutex);
|
||||
std::lock_guard lock(budget.mutex);
|
||||
failure = AVERROR(ENOMEM);
|
||||
ended = true;
|
||||
playerWake.notify_one();
|
||||
|
||||
+15
-4
@@ -182,6 +182,7 @@ struct Player {
|
||||
|
||||
// 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;
|
||||
@@ -337,8 +338,17 @@ struct Player {
|
||||
(videoFinished ? audioEnd : std::min(audioEnd, videoEnd));
|
||||
}
|
||||
end = std::min(end, duration);
|
||||
if (!std::isfinite(end) || end <= position) return;
|
||||
state.buffered_start = position;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -359,6 +369,7 @@ struct Player {
|
||||
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) {
|
||||
@@ -475,7 +486,7 @@ struct Player {
|
||||
}
|
||||
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers) {
|
||||
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake);
|
||||
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();
|
||||
@@ -608,7 +619,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, origin);
|
||||
input->start(indices, origin, canSeek);
|
||||
}
|
||||
}
|
||||
status = PLAYER_PAUSED;
|
||||
|
||||
@@ -15,7 +15,8 @@ typedef struct PlayerState {
|
||||
PlayerStatus status;
|
||||
double position;
|
||||
double duration;
|
||||
// Retained playable network media ahead, on the same timeline as position.
|
||||
// Contiguous retained/playable network range on the position timeline.
|
||||
// The start may include reusable rewind history; the end includes read-ahead.
|
||||
// Empty/unavailable (including local playback and pending seeks) is 0, 0.
|
||||
double buffered_start;
|
||||
double buffered_end;
|
||||
|
||||
Reference in New Issue
Block a user