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) {