diff --git a/FlyleafLib/MediaFramework/MediaContext/DecoderContext.cs b/FlyleafLib/MediaFramework/MediaContext/DecoderContext.cs
index baf69678..c469fecb 100644
--- a/FlyleafLib/MediaFramework/MediaContext/DecoderContext.cs
+++ b/FlyleafLib/MediaFramework/MediaContext/DecoderContext.cs
@@ -28,6 +28,25 @@ public unsafe partial class DecoderContext : PluginHandler
*/
#region Properties
+ ///
+ /// [Local patch 2026-07-20, MultiAngleVideoPlayer stepfix] Serializes
+ /// stopped-state decode operations that touch the codec/format contexts
+ /// from DIFFERENT threads: the player's seek task (Seek + GetVideoFrame)
+ /// versus frame stepping (VideoDecoder.GetFrame/GetFrameNext via
+ /// Player.ShowFrame/ShowFrameNext/ShowFramePrev). The step functions take
+ /// no internal locks (see the VideoDecoder TBR header) and relied on
+ /// "locks at higher level" — but the seek task runs OUTSIDE
+ /// Player.lockActions, so a step's Flush/decode could run concurrently
+ /// with the seek task's avcodec_send_packet on the SAME AVCodecContext,
+ /// corrupting native state (0xC0000005 / 0xC0000374 / 0xC0000409 —
+ /// reproducible with 3-4 players rapid-stepping while seeks were still
+ /// in flight). This lock is always the OUTERMOST in both paths, so the
+ /// pre-existing (inverted) internal lock orders — DecoderContext.Seek
+ /// takes codecCtx→fmtCtx while GetVideoFrame takes fmtCtx→codecCtx —
+ /// are never composed across threads.
+ ///
+ public readonly object StepSeekLock = new();
+
public object Tag { get; set; } // Upper Layer Object (eg. Player, Downloader) - mainly for plugins to access it
public bool EnableDecoding { get; set; }
public new bool Interrupt
@@ -506,6 +525,10 @@ public void GetVideoFrame(long timestamp = -1)
if (ret != 0)
{
av_packet_free(&packet);
+ // [Local patch eofdrain] Demuxer EOF with the target still
+ // ahead: the remaining frames (incl. the FINAL frame) sit
+ // in the codec's delay pipeline — drain before giving up.
+ DrainVideoFrame(timestamp);
return;
}
}
@@ -605,6 +628,36 @@ public void GetVideoFrame(long timestamp = -1)
return;
}
+
+ /* [Local patch eofdrain 3.10.4.3] GetVideoFrame's EOF tail: the demuxer is
+ * exhausted but the accurate-seek target lies within the codec's delay
+ * pipeline (always true for the stream's FINAL frame). Without this, an
+ * end-of-stream seek presented NOTHING while every readback claimed
+ * success. Mirrors the frame-step path's drain (VideoDecoder.
+ * DecodeFrameNext) with GetVideoFrame's own acceptance test. Caller
+ * already holds lockFmtCtx + lockCodecCtx. */
+ private void DrainVideoFrame(long timestamp)
+ {
+ if (VideoDecoder.SendDrainAVPacket() != 0)
+ return;
+
+ while (VideoDemuxer.VideoStream != null && !Interrupt)
+ {
+ if (VideoDecoder.RecvAVFrame() != 0)
+ return; // fully drained (EOF), needs input (EAGAIN) or critical
+
+ // Accurate seek with +- half frame distance (same test as above).
+ if (timestamp != -1 && !VideoDemuxer.IsLive && (long)(VideoDecoder.frame->pts * VideoStream.Timebase) - VideoDemuxer.StartTime + (VideoStream.FrameDuration / 2) < timestamp)
+ {
+ av_frame_unref(VideoDecoder.frame);
+ continue;
+ }
+
+ int ret = VideoDecoder.FillEnqueueAVFrame();
+ if (ret == 0 || ret == -1234)
+ return; // success or critical — done either way
+ }
+ }
public new void Dispose()
{
shouldDispose = true;
diff --git a/FlyleafLib/MediaFramework/MediaDecoder/VideoDecoder.cs b/FlyleafLib/MediaFramework/MediaDecoder/VideoDecoder.cs
index e3a17c6d..47e9316e 100644
--- a/FlyleafLib/MediaFramework/MediaDecoder/VideoDecoder.cs
+++ b/FlyleafLib/MediaFramework/MediaDecoder/VideoDecoder.cs
@@ -575,6 +575,19 @@ internal int SendAVPacket(AVPacket* packet)
return AVERROR_EAGAIN;
}
+ internal int SendDrainAVPacket()
+ { /* [Local patch eofdrain 3.10.4.3] Enters codec draining (send_packet
+ * with null) so the delay-pipeline tail — including the stream's FINAL
+ * frame — becomes receivable. GetVideoFrame's accurate seek otherwise
+ * returns at demuxer EOF with those frames stuck in the codec, so an
+ * end-of-stream target presented NOTHING (while CurTime echoed the
+ * seek and SeekCompleted fired). The frame-step path already drains
+ * (DecodeFrameNext); this gives the seek path the same ability. The
+ * next Flush/Seek's avcodec_flush_buffers resets draining state.
+ * Bypasses SendAVPacket deliberately: its key-packet validation
+ * dereferences the packet, and a drain request has none. */
+ return avcodec_send_packet(codecCtx, null);
+ }
internal int RecvAVFrame()
{ /* Receives frame from the decoder (avcodec_receive_frame) | Should be used as tied to Run Loop (vPackets[] / Frames[])
* - Key Frame Validation
@@ -1092,6 +1105,17 @@ public VideoFrame GetFrame(int frameNumber, bool backwards = false)
/// The next VideoFrame
public VideoFrame GetFrameNext()
{
+ // [Local patch stepfix] ENFORCE the documented contract instead of
+ // trusting callers: after a seek, VideoDemuxer.Start() leaves the
+ // demuxer thread filling queues, and its RunInternal shares fmtCtx AND
+ // the demuxer's single packet field with GetNextPacket — concurrent
+ // av_read_frame on both corrupts native state (AV in av_read_frame,
+ // reproducible with 3-4 players forward-stepping right after seeks).
+ // GetFrame() already pauses both; GetFrameNext was the missing one
+ // (see the TBR header: "Missing locks (e.g. GetFrameNext)").
+ demuxer.Pause();
+ Pause();
+
checkExtraFrames = true;
if (DecodeFrameNext() == 0)
diff --git a/FlyleafLib/MediaPlayer/Player.Extra.cs b/FlyleafLib/MediaPlayer/Player.Extra.cs
index d4252d08..149e9836 100644
--- a/FlyleafLib/MediaPlayer/Player.Extra.cs
+++ b/FlyleafLib/MediaPlayer/Player.Extra.cs
@@ -96,6 +96,10 @@ public void ShowFrame(int frameIndex)
lock (lockActions)
{
Pause();
+ // [Local patch stepfix] Serialized against the seek task (see
+ // DecoderContext.StepSeekLock).
+ lock (decoder.StepSeekLock)
+ {
dFrame = null;
sFrame = null;
Renderer.SubsDispose();
@@ -112,6 +116,7 @@ public void ShowFrame(int frameIndex)
Renderer.RenderRequest(vFrame);
UpdateCurTime(vFrame.Timestamp);
reversePlaybackResync = true;
+ }
}
}
@@ -133,6 +138,10 @@ public void ShowFrameNext()
UI(() => Status = status);
}
+ // [Local patch stepfix] Serialized against the seek task (see
+ // DecoderContext.StepSeekLock).
+ lock (decoder.StepSeekLock)
+ {
shouldFlushPrev = true;
decoder.RequiresResync = true;
@@ -162,6 +171,7 @@ public void ShowFrameNext()
Renderer.RenderRequest(vFrame);
UpdateCurTime(vFrame.Timestamp);
reversePlaybackResync = true;
+ }
}
}
public void ShowFramePrev()
@@ -179,6 +189,10 @@ public void ShowFramePrev()
UI(() => Status = status);
}
+ // [Local patch stepfix] Serialized against the seek task (see
+ // DecoderContext.StepSeekLock).
+ lock (decoder.StepSeekLock)
+ {
shouldFlushNext = true;
decoder.RequiresResync = true;
@@ -206,6 +220,7 @@ public void ShowFramePrev()
Renderer.RenderRequest(vFrame);
UpdateCurTime(vFrame.Timestamp);
+ }
}
}
diff --git a/FlyleafLib/MediaPlayer/Player.Playback.cs b/FlyleafLib/MediaPlayer/Player.Playback.cs
index d76260c6..bd76fe49 100644
--- a/FlyleafLib/MediaPlayer/Player.Playback.cs
+++ b/FlyleafLib/MediaPlayer/Player.Playback.cs
@@ -293,12 +293,20 @@ private void Seek(int ms, bool forward, bool accurate)
}
else
{
+ // [Local patch stepfix] Serialized against the frame-step
+ // paths (see DecoderContext.StepSeekLock). SeekCompleted
+ // fires OUTSIDE the lock — a subscriber calling back into
+ // a lockActions-taking API must not deadlock against a
+ // step holding lockActions and waiting for this lock.
+ int? seekCompletedMs = null;
+ lock (decoder.StepSeekLock)
+ {
decoder.PauseDecoders();
ret = decoder.Seek(seekData.accurate ? Math.Max(0, seekData.ms - 3000) : seekData.ms, seekData.forward, !seekData.accurate); // 3sec ffmpeg bug for seek accurate when fails to seek backwards (see videodecoder getframe)
if (ret < 0)
{
if (CanWarn) Log.Warn("Seek failed");
- SeekCompleted?.Invoke(this, -1);
+ seekCompletedMs = -1;
}
else if (!ReversePlayback && CanPlay)
{
@@ -310,8 +318,11 @@ private void Seek(int ms, bool forward, bool accurate)
SubtitlesDemuxer.Start();
DataDemuxer.Start();
decoder.PauseOnQueueFull();
- SeekCompleted?.Invoke(this, seekData.ms);
+ seekCompletedMs = seekData.ms;
+ }
}
+ if (seekCompletedMs is int completedMs)
+ SeekCompleted?.Invoke(this, completedMs);
}
Thread.Sleep(20);