diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs
index 5c8ebc069c..3d5f6d363f 100644
--- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs
+++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs
@@ -35,6 +35,7 @@ public sealed unsafe class FfmpegPlayer : IVideoPlayer, IDisposable
private const int OutputSampleRate = 48000;
private const int OutputChannels = 2;
+ private const double OutputBytesPerSecond = OutputSampleRate * OutputChannels * 2.0;
private const int VideoQueueCapacity = 3;
/// Pictures wider than this are scaled down during conversion - the preview never shows more, and it keeps the BGRA pool small.
@@ -304,6 +305,96 @@ public double Position
return duration > 0 ? Math.Min(value, duration) : value;
}
+ internal static double EndPosition(double duration, double observedPosition)
+ {
+ if (double.IsFinite(duration) && duration > 0)
+ {
+ return duration;
+ }
+
+ return double.IsFinite(observedPosition) && observedPosition > 0 ? observedPosition : 0;
+ }
+
+ internal static bool AudioDrainComplete(int eofSerial, int currentSerial, double audioEndPosition, double clock)
+ {
+ if (eofSerial != currentSerial)
+ {
+ return false;
+ }
+
+ return !double.IsFinite(audioEndPosition) || clock >= audioEndPosition - 0.005;
+ }
+
+ ///
+ /// State visible after a native seek failure. Only the request that actually failed may roll
+ /// back to the committed pipeline serial/position; a newer request that arrived while
+ /// av_seek_frame was blocked must remain pending.
+ ///
+ internal static (int RequestedSerial, double RequestedTarget) FailedSeekState(
+ int failedSerial,
+ int currentSerial,
+ int requestedSerial,
+ double requestedTarget,
+ double currentPosition)
+ {
+ return requestedSerial == failedSerial
+ ? (currentSerial, currentPosition)
+ : (requestedSerial, requestedTarget);
+ }
+
+ internal static int FailedSeekAudioStreamState(int failedSerial, int requestedSerial, int requestedAudioStreamIndex)
+ {
+ return requestedSerial == failedSerial ? -1 : requestedAudioStreamIndex;
+ }
+
+ internal static int NextAudioStreamIndex(IReadOnlyList streamIndexes, int committedAudioStreamIndex, int requestedAudioStreamIndex)
+ {
+ if (streamIndexes.Count == 0)
+ {
+ return -1;
+ }
+
+ var selected = requestedAudioStreamIndex >= 0 ? requestedAudioStreamIndex : committedAudioStreamIndex;
+ var current = -1;
+ for (var i = 0; i < streamIndexes.Count; i++)
+ {
+ if (streamIndexes[i] == selected)
+ {
+ current = i;
+ break;
+ }
+ }
+
+ return streamIndexes[(current + 1) % streamIndexes.Count];
+ }
+
+ internal static bool ShouldAutoRewindOnPlay(
+ bool hasOutstandingSeek,
+ bool endReached,
+ double duration,
+ double position)
+ {
+ return !hasOutstandingSeek &&
+ (endReached || (duration > 0 && position >= duration - 0.01));
+ }
+
+ internal static bool ShouldReachAudioOnlyEnd(
+ bool playing,
+ bool hasOutstandingSeek,
+ double duration,
+ double position)
+ {
+ return playing &&
+ !hasOutstandingSeek &&
+ duration > 0 &&
+ position >= duration;
+ }
+
+ internal static double WallClockPosition(double basePosition, double elapsedSeconds, double speed)
+ {
+ return basePosition + elapsedSeconds * speed;
+ }
+
public double Duration => _session?.Duration ?? 0;
public int VolumeMaximum => 100;
@@ -410,6 +501,15 @@ internal static bool AudioWriteCanAnchor(bool writeAccepted, int serial, int cur
return writeAccepted && serial == currentSerial;
}
+ ///
+ /// avcodec_send_packet(EAGAIN) means the decoder rejected the input. The same packet may be
+ /// resent only after receive_frame made progress, and only while the serial is still current.
+ ///
+ internal static bool ShouldResendPacket(int sendResult, bool receivedOutput, bool interrupted)
+ {
+ return sendResult == -ffmpeg.EAGAIN && receivedOutput && !interrupted;
+ }
+
internal static bool AudioWriteFailureIsDeviceFailure(
bool writeAccepted,
bool closing,
@@ -441,6 +541,38 @@ private static double TimestampToSeconds(long timestamp, AVRational timeBase)
return timestamp == ffmpeg.AV_NOPTS_VALUE ? double.NaN : timestamp * ffmpeg.av_q2d(timeBase);
}
+ internal static double StreamEndPosition(double formatStartSeconds, double streamStartSeconds, double streamDurationSeconds)
+ {
+ if (!double.IsFinite(streamDurationSeconds) || streamDurationSeconds <= 0)
+ {
+ return double.NaN;
+ }
+
+ var origin = double.IsFinite(formatStartSeconds) ? formatStartSeconds : 0;
+ var start = double.IsFinite(streamStartSeconds) ? streamStartSeconds : origin;
+ return Math.Max(0, start - origin + streamDurationSeconds);
+ }
+
+ internal static double PlaybackDuration(double formatDuration, double videoEndPosition, double audioEndPosition)
+ {
+ if (double.IsFinite(formatDuration) && formatDuration > 0)
+ {
+ return formatDuration;
+ }
+
+ // Zero means that playback stream is absent. A non-finite value means a selected
+ // stream has no trustworthy end; in that case a partial sibling duration must not be
+ // promoted to an authoritative total because the unknown stream may continue longer.
+ if (!double.IsFinite(videoEndPosition) || !double.IsFinite(audioEndPosition))
+ {
+ return 0;
+ }
+
+ var videoEnd = videoEndPosition > 0 ? videoEndPosition : 0;
+ var audioEnd = audioEndPosition > 0 ? audioEndPosition : 0;
+ return Math.Max(videoEnd, audioEnd);
+ }
+
private static string? DictionaryValue(AVDictionary* dictionary, string key)
{
var entry = ffmpeg.av_dict_get(dictionary, key, null, 0);
@@ -457,9 +589,11 @@ private sealed class Session : IDisposable
private readonly string _fileName;
private AVFormatContext* _format;
private readonly int _videoStreamIndex = -1;
- private int _audioStreamIndex = -1;
+ private volatile int _audioStreamIndex = -1;
private readonly List _audioStreamIndexes = new();
+ private readonly Dictionary _playbackStreamEndPositions = new();
private readonly double _startTimeSeconds;
+ private readonly double _formatDuration;
private readonly PacketQueue _videoPackets = new();
private readonly PacketQueue _audioPackets = new();
@@ -482,6 +616,7 @@ private sealed class Session : IDisposable
private readonly Lock _seekLock = new();
private int _requestedSerial;
private double _requestedTarget = -1;
+ private int _requestedAudioStreamIndex = -1;
private bool _seekPending;
private int _currentSerial; // serial the pipeline currently runs under
@@ -495,6 +630,8 @@ private sealed class Session : IDisposable
private double _audioAnchorPts = double.NaN; // media time of the first sample written since the last sink reset
private int _audioAnchorSerial = -1;
private double _audioSpeed = 1.0; // speed the audio currently queued was resampled for
+ private int _audioEofSerial = -1;
+ private double _audioEndPosition = double.NaN; // media position represented by all PCM queued for the current serial
private bool _audioSinkFailed;
// Restart tracking (see IVideoPlayer.HasPlaybackRestartedSince).
@@ -504,7 +641,11 @@ private sealed class Session : IDisposable
private volatile float _gain = 1f;
private double _speed = 1.0;
- public double Duration { get; }
+ public double Duration => PlaybackDuration(
+ _formatDuration,
+ SelectedStreamEndPosition(_videoStreamIndex),
+ SelectedStreamEndPosition(_audioStreamIndex));
+
public int VideoWidth { get; }
public int VideoHeight { get; }
public double DisplayAspectRatio { get; }
@@ -586,6 +727,27 @@ public Session(FfmpegPlayer owner, string fileName)
throw new InvalidOperationException("No audio or video stream found");
}
+ // Cache only streams this player can actually reproduce. Duration is read from UI
+ // threads while teardown may close _format, so the public getter must never walk
+ // native AVStream pointers after construction.
+ if (_hasVideo)
+ {
+ var stream = format->streams[_videoStreamIndex];
+ _playbackStreamEndPositions[_videoStreamIndex] = StreamEndPosition(
+ _startTimeSeconds,
+ TimestampToSeconds(stream->start_time, stream->time_base),
+ TimestampToSeconds(stream->duration, stream->time_base));
+ }
+
+ foreach (var audioIndex in _audioStreamIndexes)
+ {
+ var stream = format->streams[audioIndex];
+ _playbackStreamEndPositions[audioIndex] = StreamEndPosition(
+ _startTimeSeconds,
+ TimestampToSeconds(stream->start_time, stream->time_base),
+ TimestampToSeconds(stream->duration, stream->time_base));
+ }
+
if (_hasVideo)
{
var parameters = format->streams[_videoStreamIndex]->codecpar;
@@ -597,21 +759,7 @@ public Session(FfmpegPlayer owner, string fileName)
}
var duration = format->duration == ffmpeg.AV_NOPTS_VALUE ? double.NaN : format->duration / (double)ffmpeg.AV_TIME_BASE;
- if (double.IsNaN(duration) || duration <= 0)
- {
- duration = 0;
- for (var i = 0; i < (int)format->nb_streams; i++)
- {
- var stream = format->streams[i];
- var streamDuration = TimestampToSeconds(stream->duration, stream->time_base);
- if (!double.IsNaN(streamDuration) && streamDuration > duration)
- {
- duration = streamDuration;
- }
- }
- }
-
- Duration = duration;
+ _formatDuration = double.IsFinite(duration) && duration > 0 ? duration : 0;
_audioSink = CreateAudioSink();
if (_hasAudio)
@@ -632,6 +780,13 @@ public Session(FfmpegPlayer owner, string fileName)
}
}
+ private double SelectedStreamEndPosition(int streamIndex)
+ {
+ return streamIndex >= 0 && _playbackStreamEndPositions.TryGetValue(streamIndex, out var endPosition)
+ ? endPosition
+ : 0;
+ }
+
public bool IsPlaying => _playing;
public double Volume
@@ -649,11 +804,15 @@ public double Speed
return;
}
+ // Capture the playhead while Clock() still uses the old rate. For video-only
+ // playback (or before audio has anchored), changing _speed first would reinterpret
+ // the entire elapsed wall-clock interval at the new rate and jump the seek target.
+ var position = Position;
_speed = value;
// The queued audio was resampled for the old speed and the clocks were anchored
// under it; a seek to where we are re-anchors everything at the new speed.
- Seek(Position);
+ Seek(position);
}
}
@@ -684,12 +843,12 @@ public void Play()
return;
}
- if (_endReached || (Duration > 0 && Position >= Duration - 0.01))
+ var hasOutstandingSeek = HasOutstandingSeek();
+ if (ShouldAutoRewindOnPlay(hasOutstandingSeek, _endReached, Duration, Position))
{
Seek(0);
}
- _endReached = false;
_playing = true;
_wallClockBase = _pausedPosition;
_wallClock.Restart();
@@ -727,7 +886,7 @@ public double Position
if (_endReached)
{
- return Duration;
+ return _pausedPosition;
}
return _playing ? Clock() : _pausedPosition;
@@ -741,10 +900,11 @@ public void Seek(double seconds)
_requestedSerial++;
_requestedTarget = seconds;
_seekPending = true;
- _pausedPosition = seconds;
}
- _endReached = false;
+ // Keep the committed playhead/end state unchanged until libavformat accepts the seek.
+ // Position reports _requestedTarget while the request is outstanding, so optimistic
+ // mutation here is unnecessary and would corrupt state if av_seek_frame fails.
_demuxWake.Set();
}
@@ -768,10 +928,21 @@ public bool HasPlaybackRestartedSince(long stopwatchTimestamp)
return null;
}
- var current = _audioStreamIndexes.IndexOf(_audioStreamIndex);
- var next = _audioStreamIndexes[(current + 1) % _audioStreamIndexes.Count];
- _audioStreamIndex = next;
- Seek(Position); // flushes the queues; the audio thread reopens on the first packet of the new stream
+ var position = Position;
+ int next;
+ lock (_seekLock)
+ {
+ next = NextAudioStreamIndex(_audioStreamIndexes, _audioStreamIndex, _requestedAudioStreamIndex);
+ _requestedSerial++;
+ _requestedTarget = position;
+ _requestedAudioStreamIndex = next;
+ _seekPending = true;
+ }
+
+ // Keep routing the committed track until av_seek_frame succeeds. Otherwise packets
+ // from the requested track can enter the old serial, and a failed seek leaves the
+ // decoder switched even though the playback pipeline never moved.
+ _demuxWake.Set();
var stream = _format->streams[next];
return new AudioTrackInfo
@@ -803,7 +974,15 @@ private double Clock()
}
}
- return _wallClockBase + _wallClock.Elapsed.TotalSeconds * _speed;
+ return WallClockPosition(_wallClockBase, _wallClock.Elapsed.TotalSeconds, _speed);
+ }
+
+ private bool HasOutstandingSeek()
+ {
+ lock (_seekLock)
+ {
+ return _requestedSerial != _currentSerial;
+ }
}
/// True when a seek newer than the given serial has been requested (performed or not).
@@ -824,10 +1003,13 @@ private void DemuxLoop()
{
while (!_closing)
{
- if (TryTakeSeek(out var target, out var serial))
+ if (TryTakeSeek(out var target, out var serial, out var audioStreamIndex))
{
- PerformSeek(target, serial);
- eof = false;
+ if (PerformSeek(target, serial, audioStreamIndex))
+ {
+ eof = false;
+ }
+
continue;
}
@@ -883,7 +1065,7 @@ private void DemuxLoop()
}
}
- private bool TryTakeSeek(out double target, out int serial)
+ private bool TryTakeSeek(out double target, out int serial, out int audioStreamIndex)
{
lock (_seekLock)
{
@@ -891,23 +1073,27 @@ private bool TryTakeSeek(out double target, out int serial)
{
target = 0;
serial = 0;
+ audioStreamIndex = -1;
return false;
}
_seekPending = false;
target = _requestedTarget;
serial = _requestedSerial;
+ audioStreamIndex = _requestedAudioStreamIndex;
return true;
}
}
- private void PerformSeek(double target, int serial)
+ private bool PerformSeek(double target, int serial, int audioStreamIndex)
{
var timestamp = (long)((target + _startTimeSeconds) * ffmpeg.AV_TIME_BASE);
var result = ffmpeg.av_seek_frame(_format, -1, timestamp, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (result < 0)
{
- System.Diagnostics.Debug.WriteLine($"ffmpeg seek failed: {FfmpegLibraries.ErrorText(result)}");
+ Se.LogError($"ffmpeg player: seek to {target:0.###} s failed ({FfmpegLibraries.ErrorText(result)})");
+ RollBackFailedSeek(serial);
+ return false;
}
// Fence the sink first. Any old-serial writer that was already in flight is aborted
@@ -917,9 +1103,21 @@ private void PerformSeek(double target, int serial)
lock (_seekLock)
{
_currentSerial = serial;
+ if (audioStreamIndex >= 0)
+ {
+ _audioStreamIndex = audioStreamIndex;
+ }
+
+ if (_requestedSerial == serial)
+ {
+ _requestedAudioStreamIndex = -1;
+ }
+
_audioAnchorPts = double.NaN;
_audioAnchorSerial = -1;
_audioSpeed = _speed;
+ _audioEofSerial = -1;
+ _audioEndPosition = double.NaN;
_audioSinkFailed = false;
_wallClockBase = target;
if (_playing)
@@ -932,10 +1130,38 @@ private void PerformSeek(double target, int serial)
}
}
+ _endReached = false;
_videoPackets.Flush(serial, target);
_audioPackets.Flush(serial, target);
_videoFrames.Flush();
_presentWake.Set();
+ return true;
+ }
+
+ private void RollBackFailedSeek(int serial)
+ {
+ // Seek no longer mutates the committed paused position, so this remains the actual
+ // position when paused. While playing, Clock() follows the still-current pipeline.
+ var currentPosition = _playing ? Clock() : _pausedPosition;
+
+ lock (_seekLock)
+ {
+ var requestedSerial = _requestedSerial;
+ var state = FailedSeekState(
+ serial,
+ _currentSerial,
+ requestedSerial,
+ _requestedTarget,
+ currentPosition);
+
+ _requestedAudioStreamIndex = FailedSeekAudioStreamState(
+ serial,
+ requestedSerial,
+ _requestedAudioStreamIndex);
+ _requestedSerial = state.RequestedSerial;
+ _requestedTarget = state.RequestedTarget;
+ }
+ _presentWake.Set();
}
// ---------------------------------------------------------------- video decode
@@ -969,6 +1195,7 @@ private void VideoLoop()
var serial = -1;
var dropUntil = -1.0;
var presentedForSerial = false;
+ var videoEndPosition = double.NaN;
VideoFrame? lastDropped = null; // kept so a target past the last picture still shows something
while (!_closing)
@@ -984,6 +1211,7 @@ private void VideoLoop()
serial = entry.Serial;
dropUntil = entry.SeekTarget;
presentedForSerial = false;
+ videoEndPosition = double.NaN;
_videoFrames.Return(lastDropped);
lastDropped = null;
}
@@ -1042,7 +1270,21 @@ private void VideoLoop()
pts = TimestampToSeconds(picture->pts, timeBase);
}
- pts = double.IsNaN(pts) ? 0 : pts - _startTimeSeconds;
+ if (double.IsNaN(pts))
+ {
+ pts = double.IsFinite(videoEndPosition)
+ ? videoEndPosition
+ : Math.Max(0, dropUntil);
+ }
+ else
+ {
+ pts -= _startTimeSeconds;
+ }
+
+ var decodedEnd = pts + frameDuration;
+ videoEndPosition = double.IsFinite(videoEndPosition)
+ ? Math.Max(videoEndPosition, decodedEnd)
+ : decodedEnd;
var (targetWidth, targetHeight) = OutputSize(picture->width, picture->height);
var format = (AVPixelFormat)picture->format;
@@ -1127,7 +1369,9 @@ private void VideoLoop()
{
marker.Serial = serial;
marker.IsEndOfStream = true;
- marker.Pts = double.MaxValue;
+ marker.Pts = double.IsFinite(videoEndPosition)
+ ? Math.Max(0, videoEndPosition)
+ : Math.Max(0, dropUntil);
_videoFrames.Push(marker);
}
@@ -1406,6 +1650,8 @@ private void AudioLoop()
var dropUntil = -1.0;
var timeBase = default(AVRational);
var anchored = false;
+ var queuedAudioEnd = double.NaN;
+ var inferredAudioPts = 0.0;
while (!_closing)
{
@@ -1415,16 +1661,6 @@ private void AudioLoop()
}
var packet = entry.Packet;
- if (ShouldDrainAudioAfterSinkFailure())
- {
- if (packet != null)
- {
- ffmpeg.av_packet_free(&packet);
- }
-
- continue;
- }
-
if (packet != null && packet->stream_index != codecStreamIndex)
{
if (codec != null)
@@ -1454,39 +1690,55 @@ private void AudioLoop()
serial = entry.Serial;
dropUntil = entry.SeekTarget;
anchored = false;
+ queuedAudioEnd = double.NaN;
+ inferredAudioPts = Math.Max(0, dropUntil);
if (swr != null)
{
ffmpeg.swr_free(&swr); // forget buffered samples from before the seek
}
}
+ retryAudioPacket:
var sendResult = ffmpeg.avcodec_send_packet(codec, packet);
- if (packet != null)
+ var packetRejected = sendResult == -ffmpeg.EAGAIN;
+ if (!packetRejected && packet != null)
{
ffmpeg.av_packet_free(&packet);
}
- if (sendResult < 0 && sendResult != -ffmpeg.EAGAIN && sendResult != ffmpeg.AVERROR_EOF)
+ if (sendResult < 0 && !packetRejected && sendResult != ffmpeg.AVERROR_EOF)
{
continue;
}
+ var receiveResult = 0;
+ var receivedOutput = false;
while (!_closing)
{
- var receiveResult = ffmpeg.avcodec_receive_frame(codec, frame);
+ receiveResult = ffmpeg.avcodec_receive_frame(codec, frame);
if (receiveResult < 0)
{
break;
}
+ receivedOutput = true;
var pts = TimestampToSeconds(frame->best_effort_timestamp, timeBase);
if (double.IsNaN(pts))
{
pts = TimestampToSeconds(frame->pts, timeBase);
}
- pts = double.IsNaN(pts) ? 0 : pts - _startTimeSeconds;
+ if (double.IsNaN(pts))
+ {
+ pts = inferredAudioPts;
+ }
+ else
+ {
+ pts -= _startTimeSeconds;
+ }
+
var frameSeconds = frame->sample_rate > 0 ? frame->nb_samples / (double)frame->sample_rate : 0;
+ inferredAudioPts = Math.Max(inferredAudioPts, pts + frameSeconds);
if (dropUntil >= 0 && !anchored && pts + frameSeconds < dropUntil)
{
ffmpeg.av_frame_unref(frame);
@@ -1587,23 +1839,24 @@ private void AudioLoop()
}
var bytes = written * OutputChannels * 2;
- ApplyGain(pcm, bytes, _gain);
- var writeAccepted = _audioSink.Write(new ReadOnlySpan(pcm, 0, bytes), serial);
- if (!writeAccepted)
+ if (!WriteAudioChunk(
+ pcm,
+ bytes,
+ serial,
+ samplePts,
+ speed,
+ ref queuedAudioEnd,
+ out var sinkAccepted))
{
- // A seek/reset/close also rejects writes and is a normal interruption.
- // Only a rejection for the still-current serial means the device path
- // itself failed; switch the master clock to wall time in that case.
- TryFailOverAudioClock(serial);
break;
}
- if (!anchored)
+ if (!anchored && sinkAccepted)
{
lock (_seekLock)
{
- if (!AudioWriteCanAnchor(writeAccepted, serial, _currentSerial))
+ if (!AudioWriteCanAnchor(sinkAccepted, serial, _currentSerial))
{
break; // a newer seek landed after this chunk was accepted
}
@@ -1627,6 +1880,48 @@ private void AudioLoop()
anchored = true;
}
}
+
+ var interrupted = _closing ||
+ SeekRequestedSince(serial) ||
+ ShouldDrainAudioAfterSinkFailure();
+ if (ShouldResendPacket(sendResult, receivedOutput, interrupted))
+ {
+ goto retryAudioPacket;
+ }
+
+ if (packetRejected && !interrupted && !receivedOutput)
+ {
+ Se.LogError("ffmpeg player: decoder returned EAGAIN without output; dropping rejected audio packet");
+ }
+
+ if (packet != null)
+ {
+ ffmpeg.av_packet_free(&packet);
+ }
+
+ if (entry.IsEndOfStream && !_closing &&
+ (sendResult == ffmpeg.AVERROR_EOF || (sendResult >= 0 && receiveResult == ffmpeg.AVERROR_EOF)))
+ {
+ // libswresample can retain delayed output after the decoder itself is
+ // fully drained. Flush it before publishing audio EOF or the tail is cut.
+ if (swr != null && !FlushResampler(swr, ref pcm, swrSpeed, serial, ref queuedAudioEnd))
+ {
+ continue;
+ }
+
+ lock (_seekLock)
+ {
+ if (serial != _currentSerial || serial != _requestedSerial)
+ {
+ continue; // a newer seek superseded this EOF
+ }
+
+ _audioEofSerial = serial;
+ _audioEndPosition = queuedAudioEnd;
+ }
+
+ _presentWake.Set();
+ }
}
}
catch (Exception exception)
@@ -1703,6 +1998,113 @@ private bool TryFailOverAudioClock(int serial)
return true;
}
+ private bool WriteAudioChunk(
+ byte[] pcm,
+ int bytes,
+ int serial,
+ double samplePts,
+ double speed,
+ ref double queuedAudioEnd,
+ out bool sinkAccepted)
+ {
+ sinkAccepted = false;
+ if (bytes <= 0)
+ {
+ return true;
+ }
+
+ // After a real device failure the decoder must keep running so unknown-duration
+ // streams still produce timestamps/EOF. Skip native output, but keep media-end
+ // accounting so the wall clock can finish at the observed end.
+ var decodeOnly = ShouldDrainAudioAfterSinkFailure();
+ if (!decodeOnly)
+ {
+ ApplyGain(pcm, bytes, _gain);
+ sinkAccepted = _audioSink.Write(new ReadOnlySpan(pcm, 0, bytes), serial);
+ if (!sinkAccepted)
+ {
+ // Seek/reset/close rejection is a normal interruption. A rejection that still
+ // belongs to the current/latest serial is a device failure. Once failover is
+ // established, this chunk still contributes to media-end accounting even
+ // though it never became audible.
+ if (!TryFailOverAudioClock(serial))
+ {
+ return false;
+ }
+ }
+ }
+
+ var chunkMediaSeconds = bytes / OutputBytesPerSecond * speed;
+ var start = double.IsFinite(queuedAudioEnd) ? queuedAudioEnd : 0;
+ if (double.IsFinite(samplePts))
+ {
+ start = Math.Max(start, samplePts);
+ }
+
+ queuedAudioEnd = start + chunkMediaSeconds;
+
+ lock (_seekLock)
+ {
+ if (serial != _currentSerial || serial != _requestedSerial)
+ {
+ return false;
+ }
+
+ _audioEndPosition = queuedAudioEnd;
+ }
+
+ return true;
+ }
+
+ private bool FlushResampler(
+ SwrContext* swr,
+ ref byte[] pcm,
+ double speed,
+ int serial,
+ ref double queuedAudioEnd)
+ {
+ while (!_closing)
+ {
+ var outSamplesMax = ffmpeg.swr_get_out_samples(swr, 0);
+ if (outSamplesMax <= 0)
+ {
+ return true;
+ }
+
+ var needed = outSamplesMax * OutputChannels * 2;
+ if (pcm.Length < needed)
+ {
+ pcm = new byte[needed];
+ }
+
+ int written;
+ fixed (byte* pcmPtr = pcm)
+ {
+ var output = pcmPtr;
+ written = ffmpeg.swr_convert(swr, &output, outSamplesMax, null, 0);
+ }
+
+ if (written < 0)
+ {
+ Se.LogError($"ffmpeg player: failed to flush audio resampler ({FfmpegLibraries.ErrorText(written)})");
+ return false;
+ }
+
+ if (written == 0)
+ {
+ return true;
+ }
+
+ var bytes = written * OutputChannels * 2;
+ if (!WriteAudioChunk(pcm, bytes, serial, double.NaN, speed, ref queuedAudioEnd, out _))
+ {
+ return false;
+ }
+ }
+
+ return false;
+ }
+
private static void ApplyGain(byte[] pcm, int bytes, float gain)
{
if (Math.Abs(gain - 1f) < 0.001f)
@@ -1754,26 +2156,51 @@ private void PresentLoop()
if (!_playing)
{
// Keep the marker: it is what tells a later Play that the end was
- // reached, so the clock does not run on past the duration.
+ // reached, so the clock does not run on past the observed end.
_presentWake.WaitOne(50);
continue;
}
+ var observedEnd = frame.Pts;
+ if (SeekRequestedSince(frame.Serial))
+ {
+ // Keep the old EOS marker until the seek commits or fails. Without
+ // this guard a video-only stream can ReachEnd while av_seek_frame is
+ // still blocked and lose a Play issued for the requested destination.
+ _presentWake.WaitOne(20);
+ continue;
+ }
+
if (_hasAudio)
{
- // Video ended first; let the audio play out before stopping. The wake
- // event is also set by Play/Pause/Seek and by pushed pictures, so one
- // wait is not enough - keep waiting until the clock reaches the end,
- // unless playback was paused or a seek moved the pipeline on. The
- // marker stays queued meanwhile so a pause here still knows the end.
+ // Video can finish before audio. For a known duration, retain the old
+ // duration/stall fallback. For an unknown duration, wait for the audio
+ // decoder/resampler EOF of this seek serial and for the sink clock to
+ // reach the last PCM that was actually queued.
var interrupted = false;
var lastClock = double.NegativeInfinity;
var stalledSince = Stopwatch.GetTimestamp();
while (_playing && !_closing)
{
var now = Clock();
- if (now >= Duration - 0.05)
+ int audioEofSerial;
+ double audioEnd;
+ lock (_seekLock)
+ {
+ audioEofSerial = _audioEofSerial;
+ audioEnd = _audioEndPosition;
+ }
+
+ if (Duration > 0 && now >= Duration - 0.05)
+ {
+ observedEnd = Math.Max(observedEnd, now);
+ break;
+ }
+
+ if (AudioDrainComplete(audioEofSerial, frame.Serial, audioEnd, now))
{
+ observedEnd = Math.Max(observedEnd,
+ double.IsFinite(audioEnd) ? audioEnd : now);
break;
}
@@ -1782,9 +2209,13 @@ private void PresentLoop()
lastClock = now;
stalledSince = Stopwatch.GetTimestamp();
}
- else if (Stopwatch.GetElapsedTime(stalledSince).TotalSeconds > 0.5)
+ else if ((Duration > 0 || audioEofSerial == frame.Serial) &&
+ Stopwatch.GetElapsedTime(stalledSince).TotalSeconds > 0.5)
{
- break; // the audio ran dry short of the reported duration
+ // Known-duration legacy fallback, or a broken/stalled sink
+ // after the decoder has explicitly said no more audio exists.
+ observedEnd = Math.Max(observedEnd, now);
+ break;
}
if (SeekRequestedSince(frame.Serial))
@@ -1793,13 +2224,18 @@ private void PresentLoop()
break;
}
- _presentWake.WaitOne(Math.Clamp((int)((Duration - now) * 1000), 1, 200));
+ var waitMs = Duration > 0
+ ? Math.Clamp((int)((Duration - now) * 1000), 1, 200)
+ : 20;
+ _presentWake.WaitOne(waitMs);
}
if (interrupted || !_playing || _closing || SeekRequestedSince(frame.Serial))
{
continue;
}
+
+ observedEnd = Math.Max(observedEnd, Clock());
}
var popped = _videoFrames.Pop();
@@ -1809,7 +2245,7 @@ private void PresentLoop()
continue; // flushed by a seek while waiting
}
- ReachEnd();
+ ReachEnd(observedEnd);
continue;
}
@@ -1869,15 +2305,40 @@ private void PresentLoop()
private void PresentAudioOnlyTick()
{
- if (_playing && Duration > 0 && Clock() >= Duration)
+ if (!_playing)
+ {
+ return;
+ }
+
+ var now = Clock();
+ if (Duration > 0 && now >= Duration)
+ {
+ ReachEnd(now);
+ return;
+ }
+
+ int currentSerial;
+ int requestedSerial;
+ int audioEofSerial;
+ double audioEnd;
+ lock (_seekLock)
+ {
+ currentSerial = _currentSerial;
+ requestedSerial = _requestedSerial;
+ audioEofSerial = _audioEofSerial;
+ audioEnd = _audioEndPosition;
+ }
+
+ if (currentSerial == requestedSerial &&
+ AudioDrainComplete(audioEofSerial, currentSerial, audioEnd, now))
{
- ReachEnd();
+ ReachEnd(double.IsFinite(audioEnd) ? audioEnd : now);
}
}
- private void ReachEnd()
+ private void ReachEnd(double observedPosition)
{
- _pausedPosition = Duration;
+ _pausedPosition = EndPosition(Duration, observedPosition);
_playing = false;
_endReached = true;
_wallClock.Stop();
diff --git a/tests/UI/Logic/FfmpegPlayerTests.cs b/tests/UI/Logic/FfmpegPlayerTests.cs
index 9427280aa5..30e0bb2e50 100644
--- a/tests/UI/Logic/FfmpegPlayerTests.cs
+++ b/tests/UI/Logic/FfmpegPlayerTests.cs
@@ -301,6 +301,101 @@ public void AvCodecFileName_CarriesTheBindingsMajorVersion()
Assert.Contains(FfmpegLibraries.AvCodecMajor.ToString(), FfmpegLibraries.AvCodecFileName);
}
+ [Theory]
+ [InlineData(0.0, 0.0, 60.0, 60.0)]
+ [InlineData(0.0, 5.0, 60.0, 65.0)]
+ [InlineData(10.0, 15.0, 60.0, 65.0)]
+ [InlineData(10.0, double.NaN, 60.0, 60.0)]
+ [InlineData(10.0, 5.0, 2.0, 0.0)]
+ public void StreamEndPosition_UsesStartOffsetAndDuration(
+ double formatStart,
+ double streamStart,
+ double streamDuration,
+ double expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.StreamEndPosition(formatStart, streamStart, streamDuration));
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(-1.0)]
+ [InlineData(double.NaN)]
+ public void StreamEndPosition_UnknownDurationStaysUnknown(double streamDuration)
+ {
+ Assert.True(double.IsNaN(FfmpegPlayer.StreamEndPosition(0, 5, streamDuration)));
+ }
+
+ [Theory]
+ [InlineData(120.0, 50.0, 65.0, 120.0)]
+ [InlineData(120.0, double.NaN, double.NaN, 120.0)]
+ [InlineData(0.0, 50.0, 65.0, 65.0)]
+ [InlineData(double.NaN, 50.0, 65.0, 65.0)]
+ [InlineData(0.0, 50.0, 0.0, 50.0)]
+ [InlineData(0.0, 0.0, 0.0, 0.0)]
+ [InlineData(0.0, 50.0, double.NaN, 0.0)]
+ [InlineData(0.0, double.NaN, 65.0, 0.0)]
+ public void PlaybackDuration_PrefersContainerThenSelectedPlaybackStreams(
+ double formatDuration,
+ double videoEnd,
+ double audioEnd,
+ double expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.PlaybackDuration(formatDuration, videoEnd, audioEnd));
+ }
+
+ [Theory]
+ [InlineData(60.0, 42.5, 60.0)]
+ [InlineData(0.0, 42.5, 42.5)]
+ [InlineData(double.NaN, 42.5, 42.5)]
+ [InlineData(0.0, double.NaN, 0.0)]
+ [InlineData(0.0, -1.0, 0.0)]
+ public void EndPosition_KnownDurationOrObservedFallback(double duration, double observed, double expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.EndPosition(duration, observed));
+ }
+
+ [Theory]
+ [InlineData(3, 3, 12.0, 11.994, false)]
+ [InlineData(3, 3, 12.0, 11.995, true)]
+ [InlineData(3, 3, 12.0, 12.0, true)]
+ [InlineData(2, 3, 12.0, 99.0, false)]
+ [InlineData(3, 3, double.NaN, 0.0, true)]
+ public void AudioDrainComplete_RequiresCurrentEofAndPlayedTail(
+ int eofSerial,
+ int currentSerial,
+ double audioEnd,
+ double clock,
+ bool expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.AudioDrainComplete(eofSerial, currentSerial, audioEnd, clock));
+ }
+
+ [Theory]
+ [InlineData(0.0, 10.0, 1.0, 10.0)]
+ [InlineData(0.0, 10.0, 2.0, 20.0)]
+ [InlineData(5.0, 10.0, 0.5, 10.0)]
+ public void WallClockPosition_AppliesRateOnlyToElapsedTime(
+ double basePosition,
+ double elapsedSeconds,
+ double speed,
+ double expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.WallClockPosition(basePosition, elapsedSeconds, speed));
+ }
+
+ [Fact]
+ public void SpeedChange_MustCaptureWallClockPositionBeforeChangingRate()
+ {
+ // Ten seconds actually played at 1x is still ten seconds when the user switches to 2x.
+ // Reading Clock only after mutating the rate would reinterpret those same ten elapsed
+ // seconds at 2x and incorrectly seek to 20 s.
+ var correctTarget = FfmpegPlayer.WallClockPosition(0, 10, 1);
+ var wrongTargetIfRateChangesFirst = FfmpegPlayer.WallClockPosition(0, 10, 2);
+
+ Assert.Equal(10, correctTarget);
+ Assert.Equal(20, wrongTargetIfRateChangesFirst);
+ }
+
[Theory]
[InlineData(12.5, 60.0, 12.5)]
[InlineData(75.0, 60.0, 60.0)] // past the end: clamped to the duration
@@ -329,6 +424,107 @@ public void SeekTarget_InvalidValue_DoesNotSeek(double value)
Assert.Null(FfmpegPlayer.SeekTarget(value, 60));
}
+ [Fact]
+ public void FailedSeekState_LatestRequestRollsBackToCommittedPipeline()
+ {
+ var state = FfmpegPlayer.FailedSeekState(
+ failedSerial: 4,
+ currentSerial: 3,
+ requestedSerial: 4,
+ requestedTarget: 42.0,
+ currentPosition: 12.5);
+
+ Assert.Equal(3, state.RequestedSerial);
+ Assert.Equal(12.5, state.RequestedTarget);
+ }
+
+ [Fact]
+ public void FailedSeekState_StaleFailureDoesNotEraseNewerRequest()
+ {
+ var state = FfmpegPlayer.FailedSeekState(
+ failedSerial: 4,
+ currentSerial: 3,
+ requestedSerial: 5,
+ requestedTarget: 55.0,
+ currentPosition: 12.5);
+
+ Assert.Equal(5, state.RequestedSerial);
+ Assert.Equal(55.0, state.RequestedTarget);
+ }
+
+ [Theory]
+ [InlineData(4, 4, 7, -1)]
+ [InlineData(4, 5, 9, 9)]
+ public void FailedSeekAudioStreamState_RollsBackOnlyTheFailedLatestRequest(
+ int failedSerial,
+ int requestedSerial,
+ int requestedAudioStreamIndex,
+ int expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.FailedSeekAudioStreamState(
+ failedSerial,
+ requestedSerial,
+ requestedAudioStreamIndex));
+ }
+
+ [Fact]
+ public void NextAudioStreamIndex_UsesPendingSelectionForRapidToggles()
+ {
+ var streams = new[] { 2, 5, 8 };
+
+ Assert.Equal(8, FfmpegPlayer.NextAudioStreamIndex(streams, committedAudioStreamIndex: 5, requestedAudioStreamIndex: -1));
+ Assert.Equal(2, FfmpegPlayer.NextAudioStreamIndex(streams, committedAudioStreamIndex: 5, requestedAudioStreamIndex: 8));
+ }
+
+ [Theory]
+ [InlineData(false, true, 60.0, 60.0, true)]
+ [InlineData(false, false, 60.0, 60.0, true)]
+ [InlineData(false, false, 0.0, 60.0, false)]
+ [InlineData(true, true, 60.0, 60.0, false)]
+ [InlineData(true, false, 60.0, 60.0, false)]
+ public void ShouldAutoRewindOnPlay_DoesNotOverwriteOutstandingSeek(
+ bool hasOutstandingSeek,
+ bool endReached,
+ double duration,
+ double position,
+ bool expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.ShouldAutoRewindOnPlay(
+ hasOutstandingSeek,
+ endReached,
+ duration,
+ position));
+ }
+
+ [Theory]
+ [InlineData(true, false, 60.0, 60.0, true)]
+ [InlineData(true, true, 60.0, 60.0, false)]
+ [InlineData(false, false, 60.0, 60.0, false)]
+ [InlineData(true, false, 0.0, 60.0, false)]
+ public void ShouldReachAudioOnlyEnd_WaitsForOutstandingSeek(
+ bool playing,
+ bool hasOutstandingSeek,
+ double duration,
+ double position,
+ bool expected)
+ {
+ Assert.Equal(expected, FfmpegPlayer.ShouldReachAudioOnlyEnd(
+ playing,
+ hasOutstandingSeek,
+ duration,
+ position));
+ }
+
+ [Fact]
+ public void ShouldResendPacket_RequiresRejectedInputDecoderProgressAndCurrentSerial()
+ {
+ Assert.True(FfmpegPlayer.ShouldResendPacket(-ffmpeg.EAGAIN, receivedOutput: true, interrupted: false));
+ Assert.False(FfmpegPlayer.ShouldResendPacket(-ffmpeg.EAGAIN, receivedOutput: false, interrupted: false));
+ Assert.False(FfmpegPlayer.ShouldResendPacket(-ffmpeg.EAGAIN, receivedOutput: true, interrupted: true));
+ Assert.False(FfmpegPlayer.ShouldResendPacket(0, receivedOutput: true, interrupted: false));
+ Assert.False(FfmpegPlayer.ShouldResendPacket(ffmpeg.AVERROR_EOF, receivedOutput: true, interrupted: false));
+ }
+
[Theory]
[InlineData(AVSampleFormat.AV_SAMPLE_FMT_U8, false)]
[InlineData(AVSampleFormat.AV_SAMPLE_FMT_S16, false)]