Skip to content

Software-path seek blanks the display (flushAndRemoveImage) → black flash; should hold the last frame like the hardware path #90

Description

@rrgomes

Symptom. On the native software-decode path (dispatch → software, SoftwarePlaybackHost), every seek flashes black until the post-seek keyframe decodes — most visible on slower-decoding sources (MPEG-2). The loopback-HLS/AVPlayer path does not show this (AVPlayer holds the frame).

Root cause. SoftwarePlaybackHost.seek() calls renderer.flush(), and SampleBufferRenderer.flush() removes the displayed image (flush(removingDisplayedImage: true) / flushAndRemoveImage()) — the visible frame is cleared before the new one is ready.

Fix. Give flush() a removingDisplayedImage parameter (default true for stop/teardown) and pass false from seek(), so the previous frame stays on screen until the post-seek frame is enqueued (matches the hardware path's hold-last-frame-through-seek). Confirmed on-device: MPEG-2 episodes — seek no longer flashes black.

Patch:

diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
index 8560aad..4636898 100644
--- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
+++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
@@ -406,7 +406,10 @@ final class SoftwarePlaybackHost {
 
         videoDecoder.flush()
         audioDecoder?.flush()
-        renderer.flush()
+        // Hold the last frame through the seek (don't blank the display) so the
+        // viewer sees the previous frame until the post-seek frame decodes, instead
+        // of a black flash. Stop/teardown still clears via the default.
+        renderer.flush(removingDisplayedImage: false)
         audioOutput?.flush()
 
         // Live source is forward-only; DVR rewind reseeds decoders from the ring without touching the live demuxer's read position.
diff --git a/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift b/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift
index 8ec6727..a0cbd23 100644
--- a/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift
+++ b/Sources/AetherEngine/Renderer/SampleBufferRenderer.swift
@@ -135,8 +135,11 @@ final class SampleBufferRenderer: @unchecked Sendable {
         reorderLock.unlock()
     }
 
-    /// Discard all buffered and displayed frames (seek/stop). Clears the currently visible frame immediately.
-    func flush() {
+    /// Discard all buffered frames. `removingDisplayedImage: true` (stop/teardown)
+    /// also clears the visible frame; `false` (seek) holds the last frame on screen
+    /// until the post-seek frame is enqueued, so a seek doesn't flash black between
+    /// the old and new positions (matches the hardware path's hold-last-frame-through-seek).
+    func flush(removingDisplayedImage: Bool = true) {
         reorderLock.lock()
         reorderBuffer.removeAll()
         // Invalidate the format description cache; the next load() may open a stream with different colorimetry at the same resolution.
@@ -145,9 +148,11 @@ final class SampleBufferRenderer: @unchecked Sendable {
         reorderLock.unlock()
 
         if #available(tvOS 18.0, iOS 18.0, macOS 15.0, *) {
-            displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true) { }
-        } else {
+            displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: removingDisplayedImage) { }
+        } else if removingDisplayedImage {
             displayLayer.flushAndRemoveImage()
+        } else {
+            displayLayer.flush()
         }
     }
 

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions