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()
}
}
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()callsrenderer.flush(), andSampleBufferRenderer.flush()removes the displayed image (flush(removingDisplayedImage: true)/flushAndRemoveImage()) — the visible frame is cleared before the new one is ready.Fix. Give
flush()aremovingDisplayedImageparameter (defaulttruefor stop/teardown) and passfalsefromseek(), 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: