Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
56 changes: 53 additions & 3 deletions mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -809,6 +814,7 @@ class MacVideoPlayer {
}
bufferLock.lock()
latestPixelBuffer = pixelBuffer
frameGeneration += 1
bufferLock.unlock()
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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<MacVideoPlayer>.fromOpaque(context).takeUnretainedValue()
return player.getFrameGeneration()
}

@_cdecl("getIsHLSStream")
public func getIsHLSStream(_ context: UnsafeMutableRawPointer?) -> Bool {
guard let context = context else { return false }
Expand Down
6 changes: 6 additions & 0 deletions mediaplayer/src/jvmMain/native/macos/jni_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) {
Expand Down