diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/AvPlayerLib.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/AvPlayerLib.kt index c9f7bbdf..b365c06a 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/AvPlayerLib.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/AvPlayerLib.kt @@ -1,43 +1,78 @@ package io.github.kdroidfilter.composemediaplayer.mac -import com.sun.jna.Native -import com.sun.jna.Pointer +import java.io.File +import java.nio.ByteBuffer +import java.nio.file.Files /** - * JNA direct mapping to the native library. - * Includes methods to retrieve frame rate and metadata information. + * JNI direct mapping to the native macOS video player library. + * Handles are opaque Long values (native pointer cast to jlong, 0 = null). */ internal object SharedVideoPlayer { init { - // Register the native library for direct mapping - Native.register("NativeVideoPlayer") + loadNativeLibrary() } - @JvmStatic external fun createVideoPlayer(): Pointer? - @JvmStatic external fun openUri(context: Pointer?, uri: String?) - @JvmStatic external fun playVideo(context: Pointer?) - @JvmStatic external fun pauseVideo(context: Pointer?) - @JvmStatic external fun setVolume(context: Pointer?, volume: Float) - @JvmStatic external fun getVolume(context: Pointer?): Float - @JvmStatic external fun getLatestFrame(context: Pointer?): Pointer? - @JvmStatic external fun getFrameWidth(context: Pointer?): Int - @JvmStatic external fun getFrameHeight(context: Pointer?): Int - @JvmStatic external fun getVideoFrameRate(context: Pointer?): Float - @JvmStatic external fun getScreenRefreshRate(context: Pointer?): Float - @JvmStatic external fun getCaptureFrameRate(context: Pointer?): Float - @JvmStatic external fun getVideoDuration(context: Pointer?): Double - @JvmStatic external fun getCurrentTime(context: Pointer?): Double - @JvmStatic external fun seekTo(context: Pointer?, time: Double) - @JvmStatic external fun disposeVideoPlayer(context: Pointer?) - @JvmStatic external fun getLeftAudioLevel(context: Pointer?): Float - @JvmStatic external fun getRightAudioLevel(context: Pointer?): Float - @JvmStatic external fun setPlaybackSpeed(context: Pointer?, speed: Float) - @JvmStatic external fun getPlaybackSpeed(context: Pointer?): Float - - // Metadata retrieval functions - @JvmStatic external fun getVideoTitle(context: Pointer?): String? - @JvmStatic external fun getVideoBitrate(context: Pointer?): Long - @JvmStatic external fun getVideoMimeType(context: Pointer?): String? - @JvmStatic external fun getAudioChannels(context: Pointer?): Int - @JvmStatic external fun getAudioSampleRate(context: Pointer?): Int + private fun loadNativeLibrary() { + val osArch = System.getProperty("os.arch", "").lowercase() + val resourceDir = + if (osArch == "aarch64" || osArch == "arm64") "darwin-aarch64" else "darwin-x86-64" + val libName = "libNativeVideoPlayer.dylib" + + val stream = SharedVideoPlayer::class.java.getResourceAsStream("/$resourceDir/$libName") + ?: throw UnsatisfiedLinkError( + "Native library not found in resources: /$resourceDir/$libName" + ) + + val tempDir = Files.createTempDirectory("nativevideoplayer").toFile() + val tempFile = File(tempDir, libName) + stream.use { input -> tempFile.outputStream().use { input.copyTo(it) } } + System.load(tempFile.absolutePath) + tempFile.deleteOnExit() + tempDir.deleteOnExit() + } + + // Playback control + @JvmStatic external fun nCreatePlayer(): Long + @JvmStatic external fun nOpenUri(handle: Long, uri: String) + @JvmStatic external fun nPlay(handle: Long) + @JvmStatic external fun nPause(handle: Long) + @JvmStatic external fun nSetVolume(handle: Long, volume: Float) + @JvmStatic external fun nGetVolume(handle: Long): Float + @JvmStatic external fun nSeekTo(handle: Long, time: Double) + @JvmStatic external fun nDisposePlayer(handle: Long) + @JvmStatic external fun nSetPlaybackSpeed(handle: Long, speed: Float) + @JvmStatic external fun nGetPlaybackSpeed(handle: Long): Float + + // Frame access — lock/unlock CVPixelBuffer directly (zero intermediate copy) + // outInfo must be IntArray(3); filled with [width, height, bytesPerRow] on success. + // Returns the native base address of the locked buffer, or 0 on failure. + // MUST call nUnlockFrame after reading. + @JvmStatic external fun nLockFrame(handle: Long, outInfo: IntArray): Long + @JvmStatic external fun nUnlockFrame(handle: Long) + @JvmStatic external fun nWrapPointer(address: Long, size: Long): ByteBuffer? + @JvmStatic external fun nGetFrameWidth(handle: Long): Int + @JvmStatic external fun nGetFrameHeight(handle: Long): Int + @JvmStatic external fun nSetOutputSize(handle: Long, width: Int, height: Int): Int + + // Timing / rate info + @JvmStatic external fun nGetVideoFrameRate(handle: Long): Float + @JvmStatic external fun nGetScreenRefreshRate(handle: Long): Float + @JvmStatic external fun nGetCaptureFrameRate(handle: Long): Float + @JvmStatic external fun nGetVideoDuration(handle: Long): Double + @JvmStatic external fun nGetCurrentTime(handle: Long): Double + + // Audio levels + @JvmStatic external fun nGetLeftAudioLevel(handle: Long): Float + @JvmStatic external fun nGetRightAudioLevel(handle: Long): Float + + // Metadata + @JvmStatic external fun nGetVideoTitle(handle: Long): String? + @JvmStatic external fun nGetVideoBitrate(handle: Long): Long + @JvmStatic external fun nGetVideoMimeType(handle: Long): String? + @JvmStatic external fun nGetAudioChannels(handle: Long): Int + @JvmStatic external fun nGetAudioSampleRate(handle: Long): Int + + // Playback completion + @JvmStatic external fun nConsumeDidPlayToEnd(handle: Long): Boolean } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt index e6a9f6e3..aceb82f3 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt @@ -18,16 +18,20 @@ internal fun copyBgraFrame( dst: ByteBuffer, width: Int, height: Int, + srcBytesPerRow: Int, dstRowBytes: Int, ) { require(width > 0) { "width must be > 0 (was $width)" } require(height > 0) { "height must be > 0 (was $height)" } - val srcRowBytes = width * 4 - require(dstRowBytes >= srcRowBytes) { - "dstRowBytes ($dstRowBytes) must be >= srcRowBytes ($srcRowBytes)" + val pixelRowBytes = width * 4 + require(srcBytesPerRow >= pixelRowBytes) { + "srcBytesPerRow ($srcBytesPerRow) must be >= pixelRowBytes ($pixelRowBytes)" + } + require(dstRowBytes >= pixelRowBytes) { + "dstRowBytes ($dstRowBytes) must be >= pixelRowBytes ($pixelRowBytes)" } - val requiredSrcBytes = srcRowBytes.toLong() * height.toLong() + val requiredSrcBytes = srcBytesPerRow.toLong() * height.toLong() val requiredDstBytes = dstRowBytes.toLong() * height.toLong() require(src.capacity().toLong() >= requiredSrcBytes) { "src buffer too small: ${src.capacity()} < $requiredSrcBytes" @@ -41,25 +45,28 @@ internal fun copyBgraFrame( srcBuf.rewind() dstBuf.rewind() - if (dstRowBytes == srcRowBytes) { - srcBuf.limit(requiredSrcBytes.toInt()) - dstBuf.limit(requiredSrcBytes.toInt()) + // Fast path: both buffers have the same layout — single bulk copy + if (srcBytesPerRow == pixelRowBytes && dstRowBytes == pixelRowBytes) { + val totalBytes = pixelRowBytes.toLong() * height.toLong() + srcBuf.limit(totalBytes.toInt()) + dstBuf.limit(totalBytes.toInt()) dstBuf.put(srcBuf) return } + // Slow path: different strides — copy row by row val srcCapacity = srcBuf.capacity() val dstCapacity = dstBuf.capacity() for (row in 0 until height) { - val srcPos = row * srcRowBytes + val srcPos = row * srcBytesPerRow srcBuf.limit(srcCapacity) srcBuf.position(srcPos) - srcBuf.limit(srcPos + srcRowBytes) + srcBuf.limit(srcPos + pixelRowBytes) val dstPos = row * dstRowBytes dstBuf.limit(dstCapacity) dstBuf.position(dstPos) - dstBuf.limit(dstPos + srcRowBytes) + dstBuf.limit(dstPos + pixelRowBytes) dstBuf.put(srcBuf) } 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 3b688b2f..ca16abc1 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 @@ -11,7 +11,6 @@ import androidx.compose.ui.unit.sp import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger.Companion.setMinSeverity import co.touchlab.kermit.Severity -import com.sun.jna.Pointer import io.github.kdroidfilter.composemediaplayer.InitialPlayerState import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack @@ -24,13 +23,13 @@ import io.github.vinceglb.filekit.utils.toPath import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType import org.jetbrains.skia.ImageInfo +import java.io.File import kotlin.math.abs import kotlin.math.log10 @@ -47,9 +46,11 @@ internal val macLogger = Logger.withTag("MacVideoPlayerState") class MacVideoPlayerState : VideoPlayerState { // Main state variables - private val mainMutex = Mutex() - private val frameMutex = Mutex() - private var playerPtr: Pointer? = null + // AtomicLong allows lock-free reads of the native pointer from the frame hot path + private val playerPtrAtomic = AtomicLong(0L) + private val playerPtr: Long get() = playerPtrAtomic.get() + // Serial dispatcher for frame processing — ensures only one frame is processed at a time + private val frameDispatcher = Dispatchers.Default.limitedParallelism(1) private val _currentFrameState = MutableStateFlow(null) internal val currentFrameState: State = mutableStateOf(null) private var skiaBitmapWidth: Int = 0 @@ -64,6 +65,12 @@ class MacVideoPlayerState : VideoPlayerState { override val leftLevel: Float get() = _leftLevel.value override val rightLevel: Float get() = _rightLevel.value + // Surface display size (pixels) — used to scale native output resolution + private var surfaceWidth = 0 + private var surfaceHeight = 0 + private val isResizing = AtomicBoolean(false) + private var resizeJob: Job? = null + // Background worker threads and jobs private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var playerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -75,7 +82,6 @@ class MacVideoPlayerState : VideoPlayerState { private var lastFrameUpdateTime: Long = 0 private var seekInProgress = false private var targetSeekTime: Double? = null - private var lastFrameHash: Int = Int.MIN_VALUE private var videoFrameRate: Float = 0.0f private var screenRefreshRate: Float = 0.0f private var captureFrameRate: Float = 0.0f @@ -192,9 +198,9 @@ class MacVideoPlayerState : VideoPlayerState { private suspend fun initPlayer() = ioScope.launch { macLogger.d { "initPlayer() - Creating native player" } try { - val ptr = SharedVideoPlayer.createVideoPlayer() - if (ptr != null) { - mainMutex.withLock { playerPtr = ptr } + val ptr = SharedVideoPlayer.nCreatePlayer() + if (ptr != 0L) { + playerPtrAtomic.set(ptr) macLogger.d { "Native player created successfully" } applyVolume() applyPlaybackSpeed() @@ -216,12 +222,13 @@ class MacVideoPlayerState : VideoPlayerState { /** Updates the frame rate information from the native player. */ private suspend fun updateFrameRateInfo() { macLogger.d { "updateFrameRateInfo()" } - val ptr = mainMutex.withLock { playerPtr } ?: return + val ptr = playerPtr + if (ptr == 0L) return try { - videoFrameRate = SharedVideoPlayer.getVideoFrameRate(ptr) - screenRefreshRate = SharedVideoPlayer.getScreenRefreshRate(ptr) - captureFrameRate = SharedVideoPlayer.getCaptureFrameRate(ptr) + videoFrameRate = SharedVideoPlayer.nGetVideoFrameRate(ptr) + screenRefreshRate = SharedVideoPlayer.nGetScreenRefreshRate(ptr) + captureFrameRate = SharedVideoPlayer.nGetCaptureFrameRate(ptr) macLogger.d { "Frame Rates - Video: $videoFrameRate, Screen: $screenRefreshRate, Capture: $captureFrameRate" } } catch (e: Exception) { if (e is CancellationException) throw e @@ -231,18 +238,16 @@ class MacVideoPlayerState : VideoPlayerState { // Check if this is a local file that doesn't exist // This handles both URIs with a "file:" scheme and simple filenames without a scheme, with or without authority. + // Uses File directly to support paths with spaces or non-ASCII characters that URI.create() rejects. private fun checkExistsIfLocalFile(uri: String): Boolean { - val javaUri = try { - URI.create(uri) - } catch (e: IllegalArgumentException) { - macLogger.e(e) { "URI object is malformed: $uri" } - return false - } - return if (javaUri.scheme == "file" || javaUri.scheme == null) { - val file = javaUri.path?.toPath()?.toFile() - file?.exists() == true - } else { - true + val schemeDelimiter = uri.indexOf("://") + val scheme = if (schemeDelimiter >= 0) uri.substring(0, schemeDelimiter) else "" + return when (scheme) { + "", "file" -> { + val path = if (scheme == "file") uri.removePrefix("file://") else uri + File(path).exists() + } + else -> true // Network URI — assume reachable } } @@ -286,6 +291,11 @@ class MacVideoPlayerState : VideoPlayerState { launch { updateMetadata() } } + // Scale output to match display surface if size is already known + if (surfaceWidth > 0 && surfaceHeight > 0) { + applyOutputScaling() + } + // Update UI state on main thread withContext(Dispatchers.Main) { hasMedia = true @@ -337,19 +347,14 @@ class MacVideoPlayerState : VideoPlayerState { stopFrameUpdates() stopBufferingCheck() - val ptrToDispose = frameMutex.withLock { - lastFrameHash = Int.MIN_VALUE - mainMutex.withLock { - val ptr = playerPtr - playerPtr = null - ptr - } + val ptrToDispose = withContext(frameDispatcher) { + playerPtrAtomic.getAndSet(0L) } // Release resources outside of the mutex lock - ptrToDispose?.let { + if (ptrToDispose != 0L) { try { - SharedVideoPlayer.disposeVideoPlayer(it) + SharedVideoPlayer.nDisposePlayer(ptrToDispose) } catch (e: Exception) { if (e is CancellationException) throw e macLogger.e { "Error disposing player: ${e.message}" } @@ -364,14 +369,16 @@ class MacVideoPlayerState : VideoPlayerState { playerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) } - val isPlayerNull = mainMutex.withLock { playerPtr == null } - - if (isPlayerNull) { - val ptr = SharedVideoPlayer.createVideoPlayer() - if (ptr != null) { - mainMutex.withLock { playerPtr = ptr } - applyVolume() - applyPlaybackSpeed() + if (playerPtr == 0L) { + val ptr = SharedVideoPlayer.nCreatePlayer() + if (ptr != 0L) { + if (!playerPtrAtomic.compareAndSet(0L, ptr)) { + // Another coroutine already initialized the player; discard ours + SharedVideoPlayer.nDisposePlayer(ptr) + } else { + applyVolume() + applyPlaybackSpeed() + } } else { throw IllegalStateException("Failed to create native player") } @@ -381,7 +388,8 @@ class MacVideoPlayerState : VideoPlayerState { /** Opens media URI and returns a success flag. */ private suspend fun openMediaUri(uri: String): Boolean { macLogger.d { "openMediaUri() - Opening URI: $uri" } - val ptr = mainMutex.withLock { playerPtr } ?: return false + val ptr = playerPtr + if (ptr == 0L) return false // Check if file exists (for local files) // This handles both URIs with file:// scheme and simple filenames without a scheme @@ -394,7 +402,7 @@ class MacVideoPlayerState : VideoPlayerState { return try { // Open video asynchronously - SharedVideoPlayer.openUri(ptr, uri) + SharedVideoPlayer.nOpenUri(ptr, uri) // Instead of directly calling `updateMetadata()`, // we poll until valid dimensions are available @@ -417,10 +425,10 @@ class MacVideoPlayerState : VideoPlayerState { * are no longer zero. If dimensions are still zero after * a specified number of attempts, stop waiting. */ - private suspend fun pollDimensionsUntilReady(ptr: Pointer, maxAttempts: Int = 20) { + private suspend fun pollDimensionsUntilReady(ptr: Long, maxAttempts: Int = 20) { for (attempt in 1..maxAttempts) { - val width = SharedVideoPlayer.getFrameWidth(ptr) - val height = SharedVideoPlayer.getFrameHeight(ptr) + val width = SharedVideoPlayer.nGetFrameWidth(ptr) + val height = SharedVideoPlayer.nGetFrameHeight(ptr) if (width > 0 && height > 0) { macLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" } @@ -435,13 +443,14 @@ class MacVideoPlayerState : VideoPlayerState { /** Updates the metadata from the native player. */ private suspend fun updateMetadata() { macLogger.d { "updateMetadata()" } - val ptr = mainMutex.withLock { playerPtr } ?: return + val ptr = playerPtr + if (ptr == 0L) return try { - val width = SharedVideoPlayer.getFrameWidth(ptr) - val height = SharedVideoPlayer.getFrameHeight(ptr) - val duration = SharedVideoPlayer.getVideoDuration(ptr).toLong() - val frameRate = SharedVideoPlayer.getVideoFrameRate(ptr) + val width = SharedVideoPlayer.nGetFrameWidth(ptr) + val height = SharedVideoPlayer.nGetFrameHeight(ptr) + val duration = SharedVideoPlayer.nGetVideoDuration(ptr).toLong() + val frameRate = SharedVideoPlayer.nGetVideoFrameRate(ptr) // Calculate aspect ratio val newAspectRatio = if (width > 0 && height > 0) { @@ -453,11 +462,11 @@ class MacVideoPlayerState : VideoPlayerState { } // Get additional metadata - val title = SharedVideoPlayer.getVideoTitle(ptr) - val bitrate = SharedVideoPlayer.getVideoBitrate(ptr) - val mimeType = SharedVideoPlayer.getVideoMimeType(ptr) - val audioChannels = SharedVideoPlayer.getAudioChannels(ptr) - val audioSampleRate = SharedVideoPlayer.getAudioSampleRate(ptr) + val title = SharedVideoPlayer.nGetVideoTitle(ptr) + val bitrate = SharedVideoPlayer.nGetVideoBitrate(ptr) + val mimeType = SharedVideoPlayer.nGetVideoMimeType(ptr) + val audioChannels = SharedVideoPlayer.nGetAudioChannels(ptr) + val audioSampleRate = SharedVideoPlayer.nGetAudioSampleRate(ptr) withContext(Dispatchers.Main) { // Update metadata @@ -544,64 +553,67 @@ class MacVideoPlayerState : VideoPlayerState { /** Updates the current video frame on a background thread. */ private suspend fun updateFrameAsync() { - frameMutex.withLock { + withContext(frameDispatcher) { try { - // Safely get the player pointer - val ptr = mainMutex.withLock { playerPtr } ?: return + val ptr = playerPtr + if (ptr == 0L) return@withContext - // Get frame dimensions - val width = SharedVideoPlayer.getFrameWidth(ptr) - val height = SharedVideoPlayer.getFrameHeight(ptr) + // Lock the CVPixelBuffer directly — eliminates the Swift-side memcpy. + // outInfo = [width, height, bytesPerRow] + val outInfo = IntArray(3) + val frameAddress = SharedVideoPlayer.nLockFrame(ptr, outInfo) + if (frameAddress == 0L) return@withContext + + val width = outInfo[0] + val height = outInfo[1] + val srcBytesPerRow = outInfo[2] if (width <= 0 || height <= 0) { - return + SharedVideoPlayer.nUnlockFrame(ptr) + return@withContext } - // Get the latest frame to minimize mutex lock time - val framePtr = SharedVideoPlayer.getLatestFrame(ptr) ?: return - - val pixelCount = width * height - val frameSizeBytes = pixelCount.toLong() * 4L + val frameSizeBytes = srcBytesPerRow.toLong() * height.toLong() var framePublished = false - withContext(Dispatchers.Default) { - val srcBuf = framePtr.getByteBuffer(0, frameSizeBytes) - - // Calculate a simple hash to avoid redundant copies/conversions. - val newHash = calculateFrameHash(srcBuf, pixelCount) - if (newHash == lastFrameHash) return@withContext - lastFrameHash = newHash - - // Allocate/reuse two bitmaps (double-buffering) to avoid writing while the UI draws. - if (skiaBitmapA == null || skiaBitmapWidth != width || skiaBitmapHeight != height) { - skiaBitmapA?.close() - skiaBitmapB?.close() - - val imageInfo = ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE) - skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) } - skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) } - skiaBitmapWidth = width - skiaBitmapHeight = height - nextSkiaBitmapA = true - } + try { + withContext(Dispatchers.Default) { + val srcBuf = SharedVideoPlayer.nWrapPointer(frameAddress, frameSizeBytes) + ?: return@withContext + + // Allocate/reuse two bitmaps (double-buffering) to avoid writing while the UI draws. + if (skiaBitmapA == null || skiaBitmapWidth != width || skiaBitmapHeight != height) { + skiaBitmapA?.close() + skiaBitmapB?.close() + + val imageInfo = ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE) + skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) } + skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) } + skiaBitmapWidth = width + skiaBitmapHeight = height + nextSkiaBitmapA = true + } - val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!! - nextSkiaBitmapA = !nextSkiaBitmapA + val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!! + nextSkiaBitmapA = !nextSkiaBitmapA - val pixmap = targetBitmap.peekPixels() ?: return@withContext - val pixelsAddr = pixmap.addr - if (pixelsAddr == 0L) return@withContext + val pixmap = targetBitmap.peekPixels() ?: return@withContext + val pixelsAddr = pixmap.addr + if (pixelsAddr == 0L) return@withContext - // Native-to-native copy: frame buffer (JNA) -> Skia bitmap pixels. - srcBuf.rewind() - val destRowBytes = pixmap.rowBytes.toInt() - val destSizeBytes = destRowBytes.toLong() * height.toLong() - val destBuf = Pointer(pixelsAddr).getByteBuffer(0, destSizeBytes) - copyBgraFrame(srcBuf, destBuf, width, height, destRowBytes) + // Single copy: CVPixelBuffer → Skia bitmap pixels (no intermediate buffer) + srcBuf.rewind() + val dstRowBytes = pixmap.rowBytes.toInt() + val dstSizeBytes = dstRowBytes.toLong() * height.toLong() + val destBuf = SharedVideoPlayer.nWrapPointer(pixelsAddr, dstSizeBytes) + ?: return@withContext + copyBgraFrame(srcBuf, destBuf, width, height, srcBytesPerRow, dstRowBytes) - // Publish to flow - _currentFrameState.value = targetBitmap.asComposeImageBitmap() - framePublished = true + _currentFrameState.value = targetBitmap.asComposeImageBitmap() + framePublished = true + } + } finally { + SharedVideoPlayer.nUnlockFrame(ptr) } if (framePublished) { @@ -625,10 +637,10 @@ class MacVideoPlayerState : VideoPlayerState { if (!hasMedia) return try { - val ptr = mainMutex.withLock { playerPtr } - if (ptr != null) { - val newLeft = SharedVideoPlayer.getLeftAudioLevel(ptr) - val newRight = SharedVideoPlayer.getRightAudioLevel(ptr) + val ptr = playerPtr + if (ptr != 0L) { + val newLeft = SharedVideoPlayer.nGetLeftAudioLevel(ptr) + val newRight = SharedVideoPlayer.nGetRightAudioLevel(ptr) // macLogger.d { "Audio levels fetched: L=$newLeft, R=$newRight" } // Converts the linear level to a percentage on a logarithmic scale. @@ -699,18 +711,20 @@ class MacVideoPlayerState : VideoPlayerState { /** Checks if looping is enabled and restarts the video if needed. */ private suspend fun checkLoopingAsync(current: Double, duration: Double) { - if (current >= duration - 0.5) { - if (loop) { - macLogger.d { "checkLoopingAsync() - Loop enabled, restarting video" } - seekToAsync(0f) - } else { - macLogger.d { "checkLoopingAsync() - Video completed, updating state" } - withContext(Dispatchers.Main) { - isPlaying = false - } - // Ensure native player state is consistent - pauseInBackground() + val ptr = playerPtr + val ended = ptr != 0L && SharedVideoPlayer.nConsumeDidPlayToEnd(ptr) + // Also check position as fallback for content where the notification may not fire + if (!ended && (duration <= 0 || current < duration - 0.5)) return + + if (loop) { + macLogger.d { "checkLoopingAsync() - Loop enabled, restarting video" } + seekToAsync(0f) + } else { + macLogger.d { "checkLoopingAsync() - Video completed, updating state" } + withContext(Dispatchers.Main) { + isPlaying = false } + pauseInBackground() } } @@ -735,10 +749,11 @@ class MacVideoPlayerState : VideoPlayerState { /** Plays video on a background thread. */ private suspend fun playInBackground() { - val ptr = mainMutex.withLock { playerPtr } ?: return + val ptr = playerPtr + if (ptr == 0L) return try { - SharedVideoPlayer.playVideo(ptr) + SharedVideoPlayer.nPlay(ptr) withContext(Dispatchers.Main) { isPlaying = true @@ -762,10 +777,11 @@ class MacVideoPlayerState : VideoPlayerState { /** Pauses video on a background thread. */ private suspend fun pauseInBackground() { - val ptr = mainMutex.withLock { playerPtr } ?: return + val ptr = playerPtr + if (ptr == 0L) return try { - SharedVideoPlayer.pauseVideo(ptr) + SharedVideoPlayer.nPause(ptr) withContext(Dispatchers.Main) { isPlaying = false @@ -830,11 +846,12 @@ class MacVideoPlayerState : VideoPlayerState { lastFrameUpdateTime = System.currentTimeMillis() - val ptr = mainMutex.withLock { playerPtr } ?: return - SharedVideoPlayer.seekTo(ptr, seekTime.toDouble()) + val ptr = playerPtr + if (ptr == 0L) return + SharedVideoPlayer.nSeekTo(ptr, seekTime.toDouble()) if (isPlaying) { - SharedVideoPlayer.playVideo(ptr) + SharedVideoPlayer.nPlay(ptr) // Reduce delay to update frame faster for local videos delay(10) updateFrameAsync() @@ -872,12 +889,8 @@ class MacVideoPlayerState : VideoPlayerState { ioScope.launch { // Get player pointer and clear cached bitmaps while frame updates are paused. - val ptrToDispose = frameMutex.withLock { - val ptrToDispose = mainMutex.withLock { - val ptr = playerPtr - playerPtr = null - ptr - } + val ptrToDispose = withContext(frameDispatcher) { + val ptrToDispose = playerPtrAtomic.getAndSet(0L) skiaBitmapA?.close() skiaBitmapB?.close() @@ -891,10 +904,10 @@ class MacVideoPlayerState : VideoPlayerState { } // Dispose native resources outside the mutex lock - ptrToDispose?.let { + if (ptrToDispose != 0L) { macLogger.d { "dispose() - Disposing native player" } try { - SharedVideoPlayer.disposeVideoPlayer(it) + SharedVideoPlayer.nDisposePlayer(ptrToDispose) } catch (e: Exception) { if (e is CancellationException) throw e macLogger.e { "Error disposing player: ${e.message}" } @@ -920,7 +933,6 @@ class MacVideoPlayerState : VideoPlayerState { _aspectRatio.value = 16f / 9f error = null } - lastFrameHash = Int.MIN_VALUE _currentFrameState.value = null } @@ -954,9 +966,10 @@ class MacVideoPlayerState : VideoPlayerState { /** Retrieves the current playback time from the native player. */ private suspend fun getPositionSafely(): Double { - val ptr = mainMutex.withLock { playerPtr } ?: return 0.0 + val ptr = playerPtr + if (ptr == 0L) return 0.0 return try { - SharedVideoPlayer.getCurrentTime(ptr) + SharedVideoPlayer.nGetCurrentTime(ptr) } catch (e: Exception) { if (e is CancellationException) throw e macLogger.e { "Error getting position: ${e.message}" } @@ -966,9 +979,10 @@ class MacVideoPlayerState : VideoPlayerState { /** Retrieves the total duration of the video from the native player. */ private suspend fun getDurationSafely(): Double { - val ptr = mainMutex.withLock { playerPtr } ?: return 0.0 + val ptr = playerPtr + if (ptr == 0L) return 0.0 return try { - SharedVideoPlayer.getVideoDuration(ptr) + SharedVideoPlayer.nGetVideoDuration(ptr) } catch (e: Exception) { if (e is CancellationException) throw e macLogger.e { "Error getting duration: ${e.message}" } @@ -982,15 +996,12 @@ class MacVideoPlayerState : VideoPlayerState { * applied when the player is initialized. */ private suspend fun applyVolume() { - mainMutex.withLock { - playerPtr?.let { ptr -> - try { - SharedVideoPlayer.setVolume(ptr, _volumeState.value) - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error applying volume: ${e.message}" } - } - } + val ptr = playerPtr + if (ptr != 0L) try { + SharedVideoPlayer.nSetVolume(ptr, _volumeState.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + macLogger.e { "Error applying volume: ${e.message}" } } } @@ -1000,15 +1011,12 @@ class MacVideoPlayerState : VideoPlayerState { * applied when the player is initialized. */ private suspend fun applyPlaybackSpeed() { - mainMutex.withLock { - playerPtr?.let { ptr -> - try { - SharedVideoPlayer.setPlaybackSpeed(ptr, _playbackSpeedState.value) - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error applying playback speed: ${e.message}" } - } - } + val ptr = playerPtr + if (ptr != 0L) try { + SharedVideoPlayer.nSetPlaybackSpeed(ptr, _playbackSpeedState.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + macLogger.e { "Error applying playback speed: ${e.message}" } } } @@ -1055,4 +1063,42 @@ class MacVideoPlayerState : VideoPlayerState { // Any additional work related to fullscreen toggle can go here } } + + /** + * Called when the player surface is resized. Debounces rapid events and + * asks the native layer to decode at the surface size instead of native + * resolution, saving significant memory for high-resolution video. + */ + fun onResized(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + if (width == surfaceWidth && height == surfaceHeight) return + + surfaceWidth = width + surfaceHeight = height + + isResizing.set(true) + resizeJob?.cancel() + resizeJob = ioScope.launch { + delay(120) + try { + applyOutputScaling() + } finally { + isResizing.set(false) + } + } + } + + /** + * Asks the native layer to produce frames at the display surface size + * instead of full native resolution. Saves significant memory for 4K+ video. + */ + private suspend fun applyOutputScaling() { + val sw = surfaceWidth + val sh = surfaceHeight + if (sw <= 0 || sh <= 0) return + val ptr = playerPtr + if (ptr == 0L) return + + SharedVideoPlayer.nSetOutputSize(ptr, sw, sh) + } } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt index 40f63488..da31feef 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerSurface.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer import io.github.kdroidfilter.composemediaplayer.util.drawScaledImage @@ -38,7 +39,9 @@ fun MacVideoPlayerSurface( isInFullscreenWindow: Boolean = false, ) { Box( - modifier = modifier, + modifier = modifier.onSizeChanged { size -> + playerState.onResized(size.width, size.height) + }, contentAlignment = Alignment.Center ) { // Only render video in this surface if we're not in fullscreen mode or if this is the fullscreen window diff --git a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift index f938b049..5ffef3ea 100644 --- a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift +++ b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift @@ -23,14 +23,20 @@ class SharedVideoPlayer { // The actual capture frame rate (minimum of video and screen rates) private var captureFrameRate: Float = 0.0 - // Shared buffer to store the frame in BGRA format (no conversion needed) - private var frameBuffer: UnsafeMutablePointer? - private var bufferCapacity: Int = 0 + // Latest decoded CVPixelBuffer retained directly — no intermediate copy. + // The JNI side locks it for reading, copies to the Skia bitmap, then unlocks. + private var latestPixelBuffer: CVPixelBuffer? = nil + private var lockedPixelBuffer: CVPixelBuffer? = nil + private let bufferLock = NSLock() - // Frame dimensions + // Frame dimensions (scaled output — may be smaller than native to save RAM) private var frameWidth: Int = 0 private var frameHeight: Int = 0 + // Native video resolution (unscaled, as reported by the asset) + private var nativeVideoWidth: Int = 0 + private var nativeVideoHeight: Int = 0 + // Audio volume control (0.0 to 1.0) private var volume: Float = 1.0 @@ -70,6 +76,10 @@ class SharedVideoPlayer { private var bufferLikelyToKeepUpObserver: NSKeyValueObservation? private var bufferFullObserver: NSKeyValueObservation? + // End-of-playback flag (set by AVPlayerItemDidPlayToEndTime, consumed once by the Kotlin side) + private var didPlayToEnd: Bool = false + private var playbackEndObserver: NSObjectProtocol? + // HLS Error tracking private var lastError: String? = nil private var errorCount: Int = 0 @@ -161,13 +171,6 @@ class SharedVideoPlayer { self?.updateBufferStatus(from: item) } - // Monitor player time control status - if let player = player { - timeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in - self?.handleTimeControlStatus(player.timeControlStatus) - } - } - // Monitor access log for bitrate changes NotificationCenter.default.addObserver( self, @@ -727,7 +730,8 @@ class SharedVideoPlayer { if isHLSStream { frameWidth = 1920 frameHeight = 1080 - setupFrameBuffer() + nativeVideoWidth = frameWidth + nativeVideoHeight = frameHeight setupVideoOutputAndPlayer(with: asset) } return @@ -744,9 +748,10 @@ class SharedVideoPlayer { let effectiveSize = naturalSize.applying(transform) self.frameWidth = Int(abs(effectiveSize.width)) self.frameHeight = Int(abs(effectiveSize.height)) + self.nativeVideoWidth = self.frameWidth + self.nativeVideoHeight = self.frameHeight - // Continue with buffer allocation and setup - self.setupFrameBuffer() + // Continue with player setup self.setupVideoOutputAndPlayer(with: asset) } catch { print("Error loading video track properties: \(error.localizedDescription)") @@ -754,7 +759,6 @@ class SharedVideoPlayer { if self.isHLSStream { self.frameWidth = 1920 self.frameHeight = 1080 - self.setupFrameBuffer() self.setupVideoOutputAndPlayer(with: asset) } } @@ -767,25 +771,60 @@ class SharedVideoPlayer { let effectiveSize = naturalSize.applying(transform) frameWidth = Int(abs(effectiveSize.width)) frameHeight = Int(abs(effectiveSize.height)) + nativeVideoWidth = frameWidth + nativeVideoHeight = frameHeight - // Continue with buffer allocation and setup - setupFrameBuffer() + // Continue with player setup setupVideoOutputAndPlayer(with: asset) } } } - // Helper method to setup frame buffer - private func setupFrameBuffer() { - // Allocate or reuse the shared buffer if capacity matches - let totalPixels = frameWidth * frameHeight - if let buffer = frameBuffer, bufferCapacity == totalPixels { - buffer.initialize(repeating: 0, count: totalPixels) - } else { - frameBuffer?.deallocate() - frameBuffer = UnsafeMutablePointer.allocate(capacity: totalPixels) - frameBuffer?.initialize(repeating: 0, count: totalPixels) - bufferCapacity = totalPixels + // Retains the latest CVPixelBuffer for zero-copy JNI access. + // Updates frame dimensions for HLS streams where resolution may change dynamically. + private func retainLatestPixelBuffer(_ pixelBuffer: CVPixelBuffer) { + let w = CVPixelBufferGetWidth(pixelBuffer) + let h = CVPixelBufferGetHeight(pixelBuffer) + if isHLSStream && (w != frameWidth || h != frameHeight) { + frameWidth = w + frameHeight = h + nativeVideoWidth = w + nativeVideoHeight = h + } + bufferLock.lock() + latestPixelBuffer = pixelBuffer + bufferLock.unlock() + } + + // Locks the latest CVPixelBuffer and returns its base address for direct reading. + // outInfo must point to an array of 3 int32_t: [width, height, bytesPerRow]. + // Caller MUST call unlockLatestFrame() after reading. + func lockLatestFrame(_ outInfo: UnsafeMutablePointer) -> UnsafeMutableRawPointer? { + bufferLock.lock() + guard let pb = latestPixelBuffer else { + bufferLock.unlock() + return nil + } + lockedPixelBuffer = pb + bufferLock.unlock() + + CVPixelBufferLockBaseAddress(pb, .readOnly) + guard let addr = CVPixelBufferGetBaseAddress(pb) else { + CVPixelBufferUnlockBaseAddress(pb, .readOnly) + lockedPixelBuffer = nil + return nil + } + outInfo[0] = Int32(CVPixelBufferGetWidth(pb)) + outInfo[1] = Int32(CVPixelBufferGetHeight(pb)) + outInfo[2] = Int32(CVPixelBufferGetBytesPerRow(pb)) + return addr + } + + // Unlocks the CVPixelBuffer previously locked by lockLatestFrame(). + func unlockLatestFrame() { + if let pb = lockedPixelBuffer { + CVPixelBufferUnlockBaseAddress(pb, .readOnly) + lockedPixelBuffer = nil } } @@ -827,6 +866,20 @@ class SharedVideoPlayer { player = AVPlayer(playerItem: item) + // Monitor time control status for all media types (buffering, paused, playing) + timeControlStatusObserver = player?.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in + self?.handleTimeControlStatus(player.timeControlStatus) + } + + // Observe end of playback for all media types + playbackEndObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: nil + ) { [weak self] _ in + self?.didPlayToEnd = true + } + // Configure player for HLS if isHLSStream { player?.automaticallyWaitsToMinimizeStalling = true @@ -865,7 +918,7 @@ class SharedVideoPlayer { if output.hasNewPixelBuffer(forItemTime: zeroTime), let pixelBuffer = output.copyPixelBuffer(forItemTime: zeroTime, itemTimeForDisplay: nil) { - updateLatestFrameData(from: pixelBuffer) + retainLatestPixelBuffer(pixelBuffer) } } @@ -898,46 +951,10 @@ class SharedVideoPlayer { let pixelBuffer = output.copyPixelBuffer( forItemTime: currentTime, itemTimeForDisplay: nil) { - updateLatestFrameData(from: pixelBuffer) + retainLatestPixelBuffer(pixelBuffer) } } - /// Directly copies the content of the pixelBuffer into the shared buffer without conversion. - private func updateLatestFrameData(from pixelBuffer: CVPixelBuffer) { - guard let destBuffer = frameBuffer else { return } - - CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) - defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } - - guard let srcBaseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return } - let width = CVPixelBufferGetWidth(pixelBuffer) - let height = CVPixelBufferGetHeight(pixelBuffer) - let srcBytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) - - // For HLS, dimensions might change dynamically - if isHLSStream && (width != frameWidth || height != frameHeight) { - print("HLS: Resolution changed from \(frameWidth)x\(frameHeight) to \(width)x\(height)") - frameWidth = width - frameHeight = height - setupFrameBuffer() - guard frameBuffer != nil else { return } - } - - guard width == frameWidth, height == frameHeight else { - print("Unexpected dimensions: \(width)x\(height)") - return - } - - if srcBytesPerRow == width * 4 { - memcpy(destBuffer, srcBaseAddress, height * srcBytesPerRow) - } else { - for row in 0.. Float { @@ -1069,14 +1086,14 @@ class SharedVideoPlayer { process: self.tapProcess ) - var tap: Unmanaged? + var tap: MTAudioProcessingTap? // Create the audio processing tap let status = MTAudioProcessingTapCreate( kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PostEffects, &tap ) if status == noErr, let tap = tap { print("Audio tap created successfully") - inputParams.audioTapProcessor = tap.takeRetainedValue() + inputParams.audioTapProcessor = tap let audioMix = AVMutableAudioMix() audioMix.inputParameters = [inputParams] playerItem.audioMix = audioMix @@ -1111,7 +1128,7 @@ class SharedVideoPlayer { let pixelBuffer = output.copyPixelBuffer( forItemTime: currentTime, itemTimeForDisplay: nil) { - updateLatestFrameData(from: pixelBuffer) + retainLatestPixelBuffer(pixelBuffer) } } } @@ -1172,17 +1189,51 @@ class SharedVideoPlayer { return playbackSpeed } - /// Returns a pointer to the shared frame buffer. The caller should not free this pointer. - func getLatestFramePointer() -> UnsafeMutablePointer? { - return frameBuffer - } - /// Returns the width of the video frame in pixels func getFrameWidth() -> Int { return frameWidth } /// Returns the height of the video frame in pixels func getFrameHeight() -> Int { return frameHeight } + /// Scales the output to fit within (width, height) while preserving the native aspect ratio. + /// Never upscales beyond the native resolution. Recreates the pixel buffer output at the new size. + /// Returns true if dimensions actually changed. + func setOutputSize(width: Int, height: Int) -> Bool { + guard width > 0, height > 0 else { return false } + guard nativeVideoWidth > 0, nativeVideoHeight > 0 else { return false } + + let scaleX = Double(width) / Double(nativeVideoWidth) + let scaleY = Double(height) / Double(nativeVideoHeight) + let scale = min(scaleX, scaleY, 1.0) // never upscale + + // Enforce even dimensions (required by many codecs) + let newWidth = max(2, (Int(Double(nativeVideoWidth) * scale) / 2) * 2) + let newHeight = max(2, (Int(Double(nativeVideoHeight) * scale) / 2) * 2) + + if newWidth == frameWidth && newHeight == frameHeight { return false } + + frameWidth = newWidth + frameHeight = newHeight + + // Recreate AVPlayerItemVideoOutput with updated hint dimensions + if let item = player?.currentItem { + if let old = videoOutput { + item.remove(old) + } + let attrs: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: newWidth, + kCVPixelBufferHeightKey as String: newHeight, + kCVPixelBufferIOSurfacePropertiesKey as String: [:] + ] + let newOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: attrs) + item.add(newOutput) + videoOutput = newOutput + } + + return true + } + /// Returns the detected video frame rate func getVideoFrameRate() -> Float { return videoFrameRate } @@ -1265,12 +1316,21 @@ class SharedVideoPlayer { let pixelBuffer = output.copyPixelBuffer( forItemTime: newTime, itemTimeForDisplay: nil) { - updateLatestFrameData(from: pixelBuffer) + retainLatestPixelBuffer(pixelBuffer) } } } } + /// Consumes the end-of-playback flag. Returns true once per playback completion. + func consumeDidPlayToEnd() -> Bool { + if didPlayToEnd { + didPlayToEnd = false + return true + } + return false + } + /// Clean up observers private func cleanupObservers() { playerItemObserver?.invalidate() @@ -1280,6 +1340,12 @@ class SharedVideoPlayer { bufferLikelyToKeepUpObserver?.invalidate() bufferFullObserver?.invalidate() + if let observer = playbackEndObserver { + NotificationCenter.default.removeObserver(observer) + playbackEndObserver = nil + } + didPlayToEnd = false + NotificationCenter.default.removeObserver(self) } @@ -1289,11 +1355,11 @@ class SharedVideoPlayer { cleanupObservers() player = nil videoOutput = nil - if let buffer = frameBuffer { - buffer.deallocate() - frameBuffer = nil - bufferCapacity = 0 + if let pb = lockedPixelBuffer { + CVPixelBufferUnlockBaseAddress(pb, .readOnly) + lockedPixelBuffer = nil } + latestPixelBuffer = nil } } @@ -1355,14 +1421,18 @@ public func getVolume(_ context: UnsafeMutableRawPointer?) -> Float { return player.getVolume() } -@_cdecl("getLatestFrame") -public func getLatestFrame(_ context: UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? { - guard let context = context else { return nil } +@_cdecl("lockLatestFrame") +public func lockLatestFrame(_ context: UnsafeMutableRawPointer?, _ outInfo: UnsafeMutablePointer?) -> UnsafeMutableRawPointer? { + guard let context = context, let outInfo = outInfo else { return nil } let player = Unmanaged.fromOpaque(context).takeUnretainedValue() - if let ptr = player.getLatestFramePointer() { - return UnsafeMutableRawPointer(ptr) - } - return nil + return player.lockLatestFrame(outInfo) +} + +@_cdecl("unlockLatestFrame") +public func unlockLatestFrame(_ context: UnsafeMutableRawPointer?) { + guard let context = context else { return } + let player = Unmanaged.fromOpaque(context).takeUnretainedValue() + player.unlockLatestFrame() } @_cdecl("getFrameWidth") @@ -1379,6 +1449,13 @@ public func getFrameHeight(_ context: UnsafeMutableRawPointer?) -> Int32 { return Int32(player.getFrameHeight()) } +@_cdecl("setOutputSize") +public func setOutputSize(_ context: UnsafeMutableRawPointer?, _ width: Int32, _ height: Int32) -> Int32 { + guard let context = context else { return 0 } + let player = Unmanaged.fromOpaque(context).takeUnretainedValue() + return player.setOutputSize(width: Int(width), height: Int(height)) ? 1 : 0 +} + @_cdecl("getVideoFrameRate") public func getVideoFrameRate(_ context: UnsafeMutableRawPointer?) -> Float { guard let context = context else { return 0.0 } @@ -1505,6 +1582,13 @@ public func getAudioSampleRate(_ context: UnsafeMutableRawPointer?) -> Int32 { return Int32(player.getAudioSampleRate()) } +@_cdecl("consumeDidPlayToEnd") +public func consumeDidPlayToEnd(_ context: UnsafeMutableRawPointer?) -> Int32 { + guard let context = context else { return 0 } + let player = Unmanaged.fromOpaque(context).takeUnretainedValue() + return player.consumeDidPlayToEnd() ? 1 : 0 +} + // HLS-specific C exports @_cdecl("getIsHLSStream") public func getIsHLSStream(_ context: UnsafeMutableRawPointer?) -> Bool { diff --git a/mediaplayer/src/jvmMain/native/macos/build.sh b/mediaplayer/src/jvmMain/native/macos/build.sh index 9d5f124f..8bfa6018 100644 --- a/mediaplayer/src/jvmMain/native/macos/build.sh +++ b/mediaplayer/src/jvmMain/native/macos/build.sh @@ -5,33 +5,52 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" RESOURCES_DIR="$SCRIPT_DIR/../../resources" SWIFT_SOURCE="$SCRIPT_DIR/NativeVideoPlayer.swift" +JNI_BRIDGE="$SCRIPT_DIR/jni_bridge.c" -# Output directories (JNA resource path convention) +# Resolve JDK include paths (required to compile jni_bridge.c) +JAVA_HOME="${JAVA_HOME:-$(/usr/libexec/java_home 2>/dev/null || echo '')}" +if [ -z "$JAVA_HOME" ]; then + echo "ERROR: JAVA_HOME is not set and could not be detected automatically." + exit 1 +fi +JNI_INCLUDES="-I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin" + +# Output directories ARM64_DIR="$RESOURCES_DIR/darwin-aarch64" X64_DIR="$RESOURCES_DIR/darwin-x86-64" mkdir -p "$ARM64_DIR" "$X64_DIR" -echo "=== Building NativeVideoPlayer for macOS arm64 ===" -swiftc -emit-library -emit-module -module-name NativeVideoPlayer \ - -target arm64-apple-macosx14.0 \ - -o "$ARM64_DIR/libNativeVideoPlayer.dylib" \ - "$SWIFT_SOURCE" \ - -O -whole-module-optimization - -echo "=== Building NativeVideoPlayer for macOS x86_64 ===" -swiftc -emit-library -emit-module -module-name NativeVideoPlayer \ - -target x86_64-apple-macosx14.0 \ - -o "$X64_DIR/libNativeVideoPlayer.dylib" \ - "$SWIFT_SOURCE" \ - -O -whole-module-optimization - -# Clean up swift build artifacts -rm -f "$ARM64_DIR"/NativeVideoPlayer.abi.json "$ARM64_DIR"/NativeVideoPlayer.swiftdoc \ - "$ARM64_DIR"/NativeVideoPlayer.swiftmodule "$ARM64_DIR"/NativeVideoPlayer.swiftsourceinfo -rm -f "$X64_DIR"/NativeVideoPlayer.abi.json "$X64_DIR"/NativeVideoPlayer.swiftdoc \ - "$X64_DIR"/NativeVideoPlayer.swiftmodule "$X64_DIR"/NativeVideoPlayer.swiftsourceinfo +build_arch() { + local ARCH="$1" + local TARGET="${ARCH}-apple-macosx14.0" + local OUTPUT_DIR="$2" + local BRIDGE_OBJ="/tmp/jni_bridge_${ARCH}.o" + + echo "=== Compiling JNI bridge for ${ARCH} ===" + clang -c -arch "$ARCH" -target "$TARGET" \ + $JNI_INCLUDES \ + "$JNI_BRIDGE" -o "$BRIDGE_OBJ" + + echo "=== Building NativeVideoPlayer dylib for ${ARCH} ===" + swiftc -emit-library -emit-module -module-name NativeVideoPlayer \ + -target "$TARGET" \ + -o "$OUTPUT_DIR/libNativeVideoPlayer.dylib" \ + "$SWIFT_SOURCE" \ + "$BRIDGE_OBJ" \ + -O -whole-module-optimization + + # Clean up Swift build artifacts + rm -f "$OUTPUT_DIR"/NativeVideoPlayer.abi.json \ + "$OUTPUT_DIR"/NativeVideoPlayer.swiftdoc \ + "$OUTPUT_DIR"/NativeVideoPlayer.swiftmodule \ + "$OUTPUT_DIR"/NativeVideoPlayer.swiftsourceinfo + rm -f "$BRIDGE_OBJ" +} + +build_arch "arm64" "$ARM64_DIR" +build_arch "x86_64" "$X64_DIR" echo "=== Build completed ===" -echo "arm64: $ARM64_DIR/libNativeVideoPlayer.dylib" +echo "arm64: $ARM64_DIR/libNativeVideoPlayer.dylib" echo "x86_64: $X64_DIR/libNativeVideoPlayer.dylib" diff --git a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c new file mode 100644 index 00000000..ed8afd9e --- /dev/null +++ b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c @@ -0,0 +1,243 @@ +// jni_bridge.c — JNI bridge for macOS NativeVideoPlayer +// Calls Swift @_cdecl exports and registers them as JNI native methods. + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Forward declarations of Swift C exports +// --------------------------------------------------------------------------- + +extern void* createVideoPlayer(void); +extern void openUri(void* ctx, const char* uri); +extern void playVideo(void* ctx); +extern void pauseVideo(void* ctx); +extern void setVolume(void* ctx, float volume); +extern float getVolume(void* ctx); +extern void* lockLatestFrame(void* ctx, int32_t* outInfo); +extern void unlockLatestFrame(void* ctx); +extern int32_t getFrameWidth(void* ctx); +extern int32_t getFrameHeight(void* ctx); +extern int32_t setOutputSize(void* ctx, int32_t width, int32_t height); +extern float getVideoFrameRate(void* ctx); +extern float getScreenRefreshRate(void* ctx); +extern float getCaptureFrameRate(void* ctx); +extern double getVideoDuration(void* ctx); +extern double getCurrentTime(void* ctx); +extern void seekTo(void* ctx, double time); +extern void disposeVideoPlayer(void* ctx); +extern float getLeftAudioLevel(void* ctx); +extern float getRightAudioLevel(void* ctx); +extern void setPlaybackSpeed(void* ctx, float speed); +extern float getPlaybackSpeed(void* ctx); +extern const char* getVideoTitle(void* ctx); +extern int64_t getVideoBitrate(void* ctx); +extern const char* getVideoMimeType(void* ctx); +extern int32_t getAudioChannels(void* ctx); +extern int32_t getAudioSampleRate(void* ctx); +extern int32_t consumeDidPlayToEnd(void* ctx); + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +static inline void* toCtx(jlong h) { + return (void*)(uintptr_t)(uint64_t)h; +} + +// --------------------------------------------------------------------------- +// JNI implementations +// --------------------------------------------------------------------------- + +static jlong JNICALL jni_CreatePlayer(JNIEnv* env, jclass cls) { + void* ctx = createVideoPlayer(); + return ctx ? (jlong)(uintptr_t)ctx : 0L; +} + +static void JNICALL jni_OpenUri(JNIEnv* env, jclass cls, jlong handle, jstring uri) { + if (!handle || !uri) return; + const char* cUri = (*env)->GetStringUTFChars(env, uri, NULL); + if (!cUri) return; + openUri(toCtx(handle), cUri); + (*env)->ReleaseStringUTFChars(env, uri, cUri); +} + +static void JNICALL jni_Play(JNIEnv* env, jclass cls, jlong handle) { + if (handle) playVideo(toCtx(handle)); +} + +static void JNICALL jni_Pause(JNIEnv* env, jclass cls, jlong handle) { + if (handle) pauseVideo(toCtx(handle)); +} + +static void JNICALL jni_SetVolume(JNIEnv* env, jclass cls, jlong handle, jfloat volume) { + if (handle) setVolume(toCtx(handle), (float)volume); +} + +static jfloat JNICALL jni_GetVolume(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getVolume(toCtx(handle)) : 0.0f; +} + +// Locks the latest CVPixelBuffer and fills outInfo[3] = {width, height, bytesPerRow}. +// Returns the base address of the locked buffer, or 0 on failure. +// Caller MUST call jni_UnlockFrame after reading. +static jlong JNICALL jni_LockFrame(JNIEnv* env, jclass cls, jlong handle, jintArray outInfo) { + if (!handle || !outInfo) return 0L; + int32_t info[3] = {0, 0, 0}; + void* addr = lockLatestFrame(toCtx(handle), info); + if (!addr) return 0L; + (*env)->SetIntArrayRegion(env, outInfo, 0, 3, (jint*)info); + return (jlong)(uintptr_t)addr; +} + +static void JNICALL jni_UnlockFrame(JNIEnv* env, jclass cls, jlong handle) { + if (handle) unlockLatestFrame(toCtx(handle)); +} + +static jobject JNICALL jni_WrapPointer(JNIEnv* env, jclass cls, jlong address, jlong size) { + if (!address || size <= 0) return NULL; + return (*env)->NewDirectByteBuffer(env, (void*)(uintptr_t)(uint64_t)address, (jlong)size); +} + +static jint JNICALL jni_GetFrameWidth(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)getFrameWidth(toCtx(handle)) : 0; +} + +static jint JNICALL jni_GetFrameHeight(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)getFrameHeight(toCtx(handle)) : 0; +} + +static jint JNICALL jni_SetOutputSize(JNIEnv* env, jclass cls, jlong handle, jint width, jint height) { + return handle ? (jint)setOutputSize(toCtx(handle), (int32_t)width, (int32_t)height) : 0; +} + +static jfloat JNICALL jni_GetVideoFrameRate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getVideoFrameRate(toCtx(handle)) : 0.0f; +} + +static jfloat JNICALL jni_GetScreenRefreshRate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getScreenRefreshRate(toCtx(handle)) : 0.0f; +} + +static jfloat JNICALL jni_GetCaptureFrameRate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getCaptureFrameRate(toCtx(handle)) : 0.0f; +} + +static jdouble JNICALL jni_GetVideoDuration(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getVideoDuration(toCtx(handle)) : 0.0; +} + +static jdouble JNICALL jni_GetCurrentTime(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getCurrentTime(toCtx(handle)) : 0.0; +} + +static void JNICALL jni_SeekTo(JNIEnv* env, jclass cls, jlong handle, jdouble time) { + if (handle) seekTo(toCtx(handle), (double)time); +} + +static void JNICALL jni_DisposePlayer(JNIEnv* env, jclass cls, jlong handle) { + if (handle) disposeVideoPlayer(toCtx(handle)); +} + +static jfloat JNICALL jni_GetLeftAudioLevel(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getLeftAudioLevel(toCtx(handle)) : 0.0f; +} + +static jfloat JNICALL jni_GetRightAudioLevel(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getRightAudioLevel(toCtx(handle)) : 0.0f; +} + +static void JNICALL jni_SetPlaybackSpeed(JNIEnv* env, jclass cls, jlong handle, jfloat speed) { + if (handle) setPlaybackSpeed(toCtx(handle), (float)speed); +} + +static jfloat JNICALL jni_GetPlaybackSpeed(JNIEnv* env, jclass cls, jlong handle) { + return handle ? getPlaybackSpeed(toCtx(handle)) : 1.0f; +} + +static jstring JNICALL jni_GetVideoTitle(JNIEnv* env, jclass cls, jlong handle) { + if (!handle) return NULL; + const char* s = getVideoTitle(toCtx(handle)); + if (!s) return NULL; + jstring result = (*env)->NewStringUTF(env, s); + free((void*)s); + return result; +} + +static jlong JNICALL jni_GetVideoBitrate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jlong)getVideoBitrate(toCtx(handle)) : 0L; +} + +static jstring JNICALL jni_GetVideoMimeType(JNIEnv* env, jclass cls, jlong handle) { + if (!handle) return NULL; + const char* s = getVideoMimeType(toCtx(handle)); + if (!s) return NULL; + jstring result = (*env)->NewStringUTF(env, s); + free((void*)s); + return result; +} + +static jint JNICALL jni_GetAudioChannels(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)getAudioChannels(toCtx(handle)) : 0; +} + +static jint JNICALL jni_GetAudioSampleRate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)getAudioSampleRate(toCtx(handle)) : 0; +} + +static jboolean JNICALL jni_ConsumeDidPlayToEnd(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jboolean)(consumeDidPlayToEnd(toCtx(handle)) != 0) : JNI_FALSE; +} + +// --------------------------------------------------------------------------- +// Registration table +// --------------------------------------------------------------------------- + +static const JNINativeMethod g_methods[] = { + { "nCreatePlayer", "()J", (void*)jni_CreatePlayer }, + { "nOpenUri", "(JLjava/lang/String;)V", (void*)jni_OpenUri }, + { "nPlay", "(J)V", (void*)jni_Play }, + { "nPause", "(J)V", (void*)jni_Pause }, + { "nSetVolume", "(JF)V", (void*)jni_SetVolume }, + { "nGetVolume", "(J)F", (void*)jni_GetVolume }, + { "nLockFrame", "(J[I)J", (void*)jni_LockFrame }, + { "nUnlockFrame", "(J)V", (void*)jni_UnlockFrame }, + { "nWrapPointer", "(JJ)Ljava/nio/ByteBuffer;", (void*)jni_WrapPointer }, + { "nGetFrameWidth", "(J)I", (void*)jni_GetFrameWidth }, + { "nGetFrameHeight", "(J)I", (void*)jni_GetFrameHeight }, + { "nSetOutputSize", "(JII)I", (void*)jni_SetOutputSize }, + { "nGetVideoFrameRate", "(J)F", (void*)jni_GetVideoFrameRate }, + { "nGetScreenRefreshRate", "(J)F", (void*)jni_GetScreenRefreshRate }, + { "nGetCaptureFrameRate", "(J)F", (void*)jni_GetCaptureFrameRate }, + { "nGetVideoDuration", "(J)D", (void*)jni_GetVideoDuration }, + { "nGetCurrentTime", "(J)D", (void*)jni_GetCurrentTime }, + { "nSeekTo", "(JD)V", (void*)jni_SeekTo }, + { "nDisposePlayer", "(J)V", (void*)jni_DisposePlayer }, + { "nGetLeftAudioLevel", "(J)F", (void*)jni_GetLeftAudioLevel }, + { "nGetRightAudioLevel", "(J)F", (void*)jni_GetRightAudioLevel }, + { "nSetPlaybackSpeed", "(JF)V", (void*)jni_SetPlaybackSpeed }, + { "nGetPlaybackSpeed", "(J)F", (void*)jni_GetPlaybackSpeed }, + { "nGetVideoTitle", "(J)Ljava/lang/String;", (void*)jni_GetVideoTitle }, + { "nGetVideoBitrate", "(J)J", (void*)jni_GetVideoBitrate }, + { "nGetVideoMimeType", "(J)Ljava/lang/String;", (void*)jni_GetVideoMimeType }, + { "nGetAudioChannels", "(J)I", (void*)jni_GetAudioChannels }, + { "nGetAudioSampleRate", "(J)I", (void*)jni_GetAudioSampleRate }, + { "nConsumeDidPlayToEnd", "(J)Z", (void*)jni_ConsumeDidPlayToEnd }, +}; + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { + JNIEnv* env = NULL; + if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) + return -1; + + jclass cls = (*env)->FindClass( + env, "io/github/kdroidfilter/composemediaplayer/mac/SharedVideoPlayer"); + if (!cls) return -1; + + int count = (int)(sizeof(g_methods) / sizeof(g_methods[0])); + if ((*env)->RegisterNatives(env, cls, g_methods, count) < 0) + return -1; + + return JNI_VERSION_1_6; +} diff --git a/sample/iosApp/iosApp.xcodeproj/project.pbxproj b/sample/iosApp/iosApp.xcodeproj/project.pbxproj index 4ca6587d..3de70594 100644 --- a/sample/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/sample/iosApp/iosApp.xcodeproj/project.pbxproj @@ -113,8 +113,8 @@ /* Begin PBXShellScriptBuildPhase section */ A9D80A052AAB5CDE006C8738 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; alwaysOutOfDate = 1; + buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( @@ -127,7 +127,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -eo pipefail\n# Ensure we run from the iOS project folder's parent (sample)\ncd \"$SRCROOT/..\"\n\n# Prefer JDK 17 (or 21) for Gradle, if available\nif command -v /usr/libexec/java_home >/dev/null 2>&1; then\n export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || /usr/libexec/java_home -v 21 2>/dev/null || true)\nfi\n\n# Run Gradle task to build and embed the Kotlin framework for Xcode\n./../gradlew :sample:composeApp:embedAndSignAppleFrameworkForXcode --no-configuration-cache --stacktrace --info\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nset -eo pipefail\n# Ensure we run from the iOS project folder's parent (sample)\ncd \"$SRCROOT/..\"\n\n# Prefer JDK 17 (or 21) for Gradle, if available\nif command -v /usr/libexec/java_home >/dev/null 2>&1; then\n export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || /usr/libexec/java_home -v 21 2>/dev/null || true)\nfi\n\n# Run Gradle task to build and embed the Kotlin framework for Xcode\n./../gradlew :sample:composeApp:embedAndSignAppleFrameworkForXcode --no-configuration-cache --stacktrace --info"; }; /* End PBXShellScriptBuildPhase section */ @@ -325,4 +325,4 @@ /* End XCConfigurationList section */ }; rootObject = A93A952F29CC810C00F8E227 /* Project object */; -} \ No newline at end of file +}