From 537fc8268806d4b7cdc06f8980daa92400afd3cf Mon Sep 17 00:00:00 2001 From: "federico.gambassi@tomatoai.it" Date: Fri, 30 Oct 2026 18:44:55 +0100 Subject: [PATCH] fix(macos): show the current frame of a paused HLS stream AVPlayerItemVideoOutput only delivers frames while the timer pulls them during playback, and the paused-frame captures in pause(), seekTo() and at open were all skipped for HLS. A paused HLS video therefore kept a stale frame after a seek, and an HLS media opened paused showed nothing at all. - pause(), a landed seek while paused, and an HLS item turning readyToPlay while paused capture the frame at the current time. The output decodes it asynchronously, so the capture polls for up to a second. - The native side counts retained frames (getFrameGeneration). While paused, MacVideoPlayerState re-arms its one-shot thumbnail read when the count changes: the frame of a paused seek lands after the producer has already spent that read on the previous frame. Non-HLS media keep their existing path. Co-Authored-By: Claude Opus 5.5 --- .../composemediaplayer/mac/MacNativeBridge.kt | 3 + .../mac/MacVideoPlayerState.kt | 20 +++++++ .../native/macos/NativeVideoPlayer.swift | 56 ++++++++++++++++++- .../src/jvmMain/native/macos/jni_bridge.c | 6 ++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt index f21427d6..bd2fe718 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt @@ -97,4 +97,7 @@ internal object MacNativeBridge { // Playback completion @JvmStatic external fun nConsumeDidPlayToEnd(handle: Long): Boolean + + // Changes each time the native side retains a new frame + @JvmStatic external fun nGetFrameGeneration(handle: Long): Long } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt index 035445b6..679a1a8c 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt @@ -290,6 +290,13 @@ class MacVideoPlayerState : VideoPlayerState { /** Flag to track whether we've read at least one frame while paused (for the paused thumbnail). */ private val initialFrameRead = AtomicBoolean(false) + /** + * The native frame generation last seen while paused. The native side captures a frame after a paused seek + * or a paused open, asynchronously: a new generation earns the paused producer one more read. + */ + @Volatile + private var pausedFrameGeneration: Long = Long.MIN_VALUE + // Last opened URI, for play()-without-media reload. private var lastUri: String? = null @@ -388,6 +395,7 @@ class MacVideoPlayerState : VideoPlayerState { _hasMedia = false userPaused = false initialFrameRead.set(false) + pausedFrameGeneration = Long.MIN_VALUE // Discard any seek latched against the previous media; if the in-flight seek // loop only drained it after the new media finished opening, the new video // would jump to the old target. @@ -1216,6 +1224,7 @@ class MacVideoPlayerState : VideoPlayerState { while (scope.isActive && _hasMedia && !isDisposing.get()) { if (_isPlaying) return true + if (userPaused && allowInitialFrame) rearmOnNewPausedFrame() if (userPaused && allowInitialFrame && !initialFrameRead.getAndSet(true)) return true try { delay(40) @@ -1226,6 +1235,17 @@ class MacVideoPlayerState : VideoPlayerState { return false } + /** A frame the native side captured while paused (after a seek, at open) earns one more read. */ + private fun rearmOnNewPausedFrame() { + val ptr = playerPtr + if (ptr == 0L) return + val generation = MacNativeBridge.nGetFrameGeneration(ptr) + if (generation != pausedFrameGeneration) { + pausedFrameGeneration = generation + initialFrameRead.set(false) + } + } + private var resizeWaitCount = 0 private suspend fun waitIfResizing(): Boolean { diff --git a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift index 36d1318a..103b9566 100644 --- a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift +++ b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift @@ -79,6 +79,11 @@ class MacVideoPlayer { private let aspectLock = NSLock() private var cachedDisplayAspectRatio: Double = 0.0 + // Incremented each time a new frame is retained, so the Kotlin side can tell a frame captured while + // paused (after a seek, at open) from the one it already holds. + private var frameGeneration: Int64 = 0 + private var statusObserver: NSKeyValueObservation? + // End-of-playback flag (set by AVPlayerItemDidPlayToEndTime, consumed once by the Kotlin side) private var didPlayToEnd: Bool = false private var playbackEndObserver: NSObjectProtocol? @@ -809,6 +814,7 @@ class MacVideoPlayer { } bufferLock.lock() latestPixelBuffer = pixelBuffer + frameGeneration += 1 bufferLock.unlock() } @@ -900,6 +906,14 @@ class MacVideoPlayer { self?.updateCachedDisplayAspectRatio(from: item.presentationSize) } + // An HLS item opened paused shows its first frame too (non-HLS media get captureInitialFrame) + if isHLSStream { + statusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in + guard item.status == .readyToPlay else { return } + DispatchQueue.main.async { self?.capturePausedFrame() } + } + } + // Observe end of playback for all media types playbackEndObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, @@ -1106,8 +1120,10 @@ class MacVideoPlayer { player?.pause() stopDisplayLink() - // Capture the current frame to display while paused (not for HLS) - if !isHLSStream, let output = videoOutput, let item = player?.currentItem { + // Capture the current frame to display while paused + if isHLSStream { + capturePausedFrame() + } else if let output = videoOutput, let item = player?.currentItem { let currentTime = item.currentTime() if output.hasNewPixelBuffer(forItemTime: currentTime), let pixelBuffer = output.copyPixelBuffer( @@ -1118,6 +1134,20 @@ class MacVideoPlayer { } } + /// Retains the frame at the current time while paused, for HLS streams. AVPlayerItemVideoOutput decodes it + /// asynchronously after a seek or an open, so this polls for up to a second until it is there. + private func capturePausedFrame(attemptsLeft: Int = 50) { + guard !isPlaying, let output = videoOutput, let item = player?.currentItem else { return } + let currentTime = item.currentTime() + if let pixelBuffer = output.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil) { + retainLatestPixelBuffer(pixelBuffer) + } else if attemptsLeft > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.capturePausedFrame(attemptsLeft: attemptsLeft - 1) + } + } + } + /// Sets the volume level (0.0 to 1.0) func setVolume(level: Float) { volume = max(0.0, min(1.0, level)) // Clamp between 0.0 and 1.0 @@ -1269,6 +1299,13 @@ class MacVideoPlayer { /// Returns true if this is an HLS stream func getIsHLSStream() -> Bool { return isHLSStream } + /// Changes each time a new frame is retained + func getFrameGeneration() -> Int64 { + bufferLock.lock() + defer { bufferLock.unlock() } + return frameGeneration + } + /// Returns available bitrates for HLS streams func getAvailableBitrates() -> [Float] { return availableBitrates } @@ -1314,7 +1351,11 @@ class MacVideoPlayer { // For HLS, use tolerance for more efficient seeking if isHLSStream { let tolerance = CMTime(seconds: 1.0, preferredTimescale: 600) - player.seek(to: newTime, toleranceBefore: tolerance, toleranceAfter: tolerance) + // Once the seek has landed while paused, show the frame it landed on + player.seek(to: newTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] finished in + guard finished else { return } + DispatchQueue.main.async { self?.capturePausedFrame() } + } } else { player.seek(to: newTime) @@ -1349,6 +1390,8 @@ class MacVideoPlayer { bufferFullObserver?.invalidate() presentationSizeObserver?.invalidate() presentationSizeObserver = nil + statusObserver?.invalidate() + statusObserver = nil // Drop the cached aspect ratio so a reopened player can't briefly show the old video's ratio. updateCachedDisplayAspectRatio(from: .zero) @@ -1595,6 +1638,13 @@ public func consumeDidPlayToEnd(_ context: UnsafeMutableRawPointer?) -> Int32 { } // HLS-specific C exports +@_cdecl("getFrameGeneration") +public func getFrameGeneration(_ context: UnsafeMutableRawPointer?) -> Int64 { + guard let context = context else { return 0 } + let player = Unmanaged.fromOpaque(context).takeUnretainedValue() + return player.getFrameGeneration() +} + @_cdecl("getIsHLSStream") public func getIsHLSStream(_ context: UnsafeMutableRawPointer?) -> Bool { guard let context = context else { return false } diff --git a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c index cd8b4784..8b8afe72 100644 --- a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c +++ b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c @@ -36,6 +36,7 @@ extern const char* getVideoMimeType(void* ctx); extern int32_t getAudioChannels(void* ctx); extern int32_t getAudioSampleRate(void* ctx); extern int32_t consumeDidPlayToEnd(void* ctx); +extern int64_t getFrameGeneration(void* ctx); // --------------------------------------------------------------------------- // Utility @@ -185,6 +186,10 @@ static jboolean JNICALL jni_ConsumeDidPlayToEnd(JNIEnv* env, jclass cls, jlong h return handle ? (jboolean)(consumeDidPlayToEnd(toCtx(handle)) != 0) : JNI_FALSE; } +static jlong JNICALL jni_GetFrameGeneration(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jlong)getFrameGeneration(toCtx(handle)) : 0L; +} + // --------------------------------------------------------------------------- // Registration table // --------------------------------------------------------------------------- @@ -218,6 +223,7 @@ static const JNINativeMethod g_methods[] = { { "nGetAudioChannels", "(J)I", (void*)jni_GetAudioChannels }, { "nGetAudioSampleRate", "(J)I", (void*)jni_GetAudioSampleRate }, { "nConsumeDidPlayToEnd", "(J)Z", (void*)jni_ConsumeDidPlayToEnd }, + { "nGetFrameGeneration", "(J)J", (void*)jni_GetFrameGeneration }, }; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {