From b46c441bee9a06b0b5cd6e139c7833d8b7aa1f00 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Fri, 10 Apr 2026 09:16:32 +0300 Subject: [PATCH] Migrate Linux player from GStreamer Java/JNA to native C via JNI Replace the entire Linux video player backend with a native C library using GStreamer C API directly, matching the macOS/Windows JNI architecture. - Add native C GStreamer player (playbin + appsink + scaletempo + level) - Add JNI bridge with 27 native methods - Add CMake build system and build.sh for Linux - Add Kotlin SharedVideoPlayer JNI binding - Rewrite LinuxVideoPlayerState with coroutine-driven polling (like Mac) - Add output scaling support via appsink caps - Add queue element for thread decoupling between decoder and frame extraction - Add dedicated bus polling thread (no GLib main loop needed) - Remove GStreamer Java (gst1-java-core) dependency - Remove JNA dependency entirely, replace with CurrentPlatform utility - Update all tests to use CurrentPlatform instead of JNA Platform --- mediaplayer/build.gradle.kts | 24 +- .../VideoPlayerState.jvm.kt | 11 +- .../composemediaplayer/linux/GStreamerInit.kt | 94 -- .../linux/LinuxFrameUtils.kt | 37 +- .../linux/LinuxVideoPlayerState.kt | 1457 ++++++++--------- .../linux/LinuxVideoPlayerSurface.jvm.kt | 79 +- .../linux/SharedVideoPlayer.kt | 71 + .../util/CurrentPlatform.kt | 18 + .../src/jvmMain/native/linux/CMakeLists.txt | 58 + .../jvmMain/native/linux/NativeVideoPlayer.c | 712 ++++++++ .../jvmMain/native/linux/NativeVideoPlayer.h | 59 + mediaplayer/src/jvmMain/native/linux/build.sh | 31 + .../src/jvmMain/native/linux/jni_bridge.c | 190 +++ .../linux-x86-64/libNativeVideoPlayer.so | Bin 0 -> 42160 bytes .../VideoPlayerStateTest.kt | 98 +- .../linux/LinuxVideoPlayerStateTest.kt | 74 +- .../mac/MacVideoPlayerStateTest.kt | 16 +- .../windows/WindowsVideoPlayerStateTest.kt | 12 +- 18 files changed, 1940 insertions(+), 1101 deletions(-) delete mode 100644 mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/GStreamerInit.kt create mode 100644 mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer.kt create mode 100644 mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/CurrentPlatform.kt create mode 100644 mediaplayer/src/jvmMain/native/linux/CMakeLists.txt create mode 100644 mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c create mode 100644 mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h create mode 100755 mediaplayer/src/jvmMain/native/linux/build.sh create mode 100644 mediaplayer/src/jvmMain/native/linux/jni_bridge.c create mode 100755 mediaplayer/src/jvmMain/resources/linux-x86-64/libNativeVideoPlayer.so diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts index 502a319e..ae351348 100644 --- a/mediaplayer/build.gradle.kts +++ b/mediaplayer/build.gradle.kts @@ -108,9 +108,6 @@ kotlin { jvmMain.dependencies { implementation(libs.kotlinx.coroutines.swing) - implementation(libs.gst1.java.core) - implementation(libs.jna.jpms) - implementation(libs.jna.platform.jpms) implementation(libs.slf4j.simple) } @@ -197,13 +194,30 @@ val buildNativeWindows by tasks.registering(Exec::class) { commandLine("cmd", "/c", nativeDir.file("build.bat").asFile.absolutePath) } +val buildNativeLinux by tasks.registering(Exec::class) { + description = "Compiles the C native library into Linux .so (GStreamer + JNI)" + group = "build" + val hasPrebuilt = nativeResourceDir + .dir("linux-x86-64") + .file("libNativeVideoPlayer.so") + .asFile + .exists() + enabled = Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC) && !hasPrebuilt + + val nativeDir = layout.projectDirectory.dir("src/jvmMain/native/linux") + inputs.dir(nativeDir) + outputs.dir(nativeResourceDir) + workingDir(nativeDir) + commandLine("bash", "build.sh") +} + tasks.named("jvmProcessResources") { - dependsOn(buildNativeMacOs, buildNativeWindows) + dependsOn(buildNativeMacOs, buildNativeWindows, buildNativeLinux) } tasks.configureEach { if (name == "sourcesJar") { - dependsOn(buildNativeMacOs, buildNativeWindows) + dependsOn(buildNativeMacOs, buildNativeWindows, buildNativeLinux) } } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt index bdb5b7dd..15dcdbf1 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt @@ -3,9 +3,9 @@ package io.github.kdroidfilter.composemediaplayer import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle -import com.sun.jna.Platform import io.github.kdroidfilter.composemediaplayer.linux.LinuxVideoPlayerState import io.github.kdroidfilter.composemediaplayer.mac.MacVideoPlayerState +import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform import io.github.kdroidfilter.composemediaplayer.windows.WindowsVideoPlayerState import io.github.vinceglb.filekit.PlatformFile @@ -37,11 +37,10 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState( */ @Stable open class DefaultVideoPlayerState: VideoPlayerState { - val delegate: VideoPlayerState = when { - Platform.isWindows() -> WindowsVideoPlayerState() - Platform.isMac() -> MacVideoPlayerState() - Platform.isLinux() -> LinuxVideoPlayerState() - else -> throw UnsupportedOperationException("Unsupported platform") + val delegate: VideoPlayerState = when (CurrentPlatform.os) { + CurrentPlatform.OS.WINDOWS -> WindowsVideoPlayerState() + CurrentPlatform.OS.MAC -> MacVideoPlayerState() + CurrentPlatform.OS.LINUX -> LinuxVideoPlayerState() } override val hasMedia: Boolean get() = delegate.hasMedia diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/GStreamerInit.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/GStreamerInit.kt deleted file mode 100644 index 19f74540..00000000 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/GStreamerInit.kt +++ /dev/null @@ -1,94 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer.linux - -import com.sun.jna.Platform -import com.sun.jna.platform.win32.Kernel32 -import org.freedesktop.gstreamer.Gst -import org.freedesktop.gstreamer.Version -import java.io.File - -/** - * GStreamerInit is a singleton object responsible for initializing the GStreamer library. - * - * This object ensures that the GStreamer library is initialized only once during the application's - * execution. The initialization process configures the library with the base version and a specific - * application name. - * - * It guarantees that GStreamer is properly prepared before any media-related operations, - * particularly with components such as media players. - * - * Features: - * - Tracks the initialization state to prevent duplicate initializations. - * - Configures GStreamer with a specified version and application identifier. - * - Allows the user to set custom paths to the GStreamer libraries for Windows and macOS. - */ -object GStreamerInit { - private var initialized = false - private var userGstPathWindows: String? = null - private var userGstPathMac: String? = null - - /** - * Allows the user to set a custom path for GStreamer on Windows. - * - * @param path Path to the GStreamer bin directory. - */ - fun setGStreamerPathWindows(path: String) { - userGstPathWindows = path - } - - /** - * Allows the user to set a custom path for GStreamer on macOS. - * - * @param path Path to the GStreamer Libraries directory. - */ - fun setGStreamerPathMac(path: String) { - userGstPathMac = path - } - - /** - * Initializes GStreamer if it hasn't been initialized already. - */ - fun init() { - if (!initialized) { - configurePaths() - Gst.init(Version.BASELINE, "ComposeGStreamerPlayer") - initialized = true - } - } - - /** - * Configures the paths to the GStreamer libraries. - * On Windows, uses the specified environment variables or default paths. - * On macOS, adds the path to jna.library.path or uses default values. - * On Linux, assumes that GStreamer is already in the PATH. - */ - private fun configurePaths() { - when { - Platform.isWindows() -> { - val gstPath = userGstPathWindows ?: System.getProperty("gstreamer.path", "C:\\gstreamer\\1.0\\msvc_x86_64\\bin") - if (gstPath.isNotEmpty()) { - val systemPath = System.getenv("PATH") - if (systemPath.isNullOrBlank()) { - Kernel32.INSTANCE.SetEnvironmentVariable("PATH", gstPath) - } else { - Kernel32.INSTANCE.SetEnvironmentVariable( - "PATH", "$gstPath${File.pathSeparator}$systemPath" - ) - } - } - } - - Platform.isMac() -> { - val gstPath = userGstPathMac ?: System.getProperty("gstreamer.path", "/Library/Frameworks/GStreamer.framework/Libraries/") - if (gstPath.isNotEmpty()) { - val jnaPath = System.getProperty("jna.library.path", "").trim() - if (jnaPath.isEmpty()) { - System.setProperty("jna.library.path", gstPath) - } else { - System.setProperty("jna.library.path", "$jnaPath${File.pathSeparator}$gstPath") - } - } - } - // For Linux, no action required if GStreamer is already in the PATH - } - } -} diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFrameUtils.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFrameUtils.kt index b41c47a7..a4769e44 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFrameUtils.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxFrameUtils.kt @@ -2,40 +2,7 @@ package io.github.kdroidfilter.composemediaplayer.linux import java.nio.ByteBuffer -/** - * Calculates a fast hash of the frame buffer to detect frame changes. - * Samples approximately 200 pixels evenly distributed across the frame. - * - * @param buffer The source buffer containing RGBA pixel data - * @param pixelCount Total number of pixels in the frame - * @return A hash value representing the frame content - */ -internal fun calculateFrameHash(buffer: ByteBuffer, pixelCount: Int): Int { - if (pixelCount <= 0) return 0 - - var hash = 1 - val step = if (pixelCount <= 200) 1 else pixelCount / 200 - for (i in 0 until pixelCount step step) { - hash = 31 * hash + buffer.getInt(i * 4) - } - return hash -} - -/** - * Copies RGBA frame data from source to destination buffer with minimal overhead. - * Handles row padding when destination stride differs from source. - * - * This function performs a single memory copy operation when strides match, - * achieving zero-copy performance (beyond the necessary single copy from - * GStreamer buffer to Skia bitmap). - * - * @param src Source buffer containing RGBA pixel data from GStreamer - * @param dst Destination buffer (Skia bitmap pixels via peekPixels) - * @param width Frame width in pixels - * @param height Frame height in pixels - * @param dstRowBytes Destination row stride (may include padding) - */ -internal fun copyRgbaFrame( +internal fun copyBgraFrame( src: ByteBuffer, dst: ByteBuffer, width: Int, @@ -63,7 +30,6 @@ internal fun copyRgbaFrame( srcBuf.rewind() dstBuf.rewind() - // Fast path: when strides match, do a single bulk copy if (dstRowBytes == srcRowBytes) { srcBuf.limit(requiredSrcBytes.toInt()) dstBuf.limit(requiredSrcBytes.toInt()) @@ -71,7 +37,6 @@ internal fun copyRgbaFrame( return } - // Slow path: copy row by row when there's padding val srcCapacity = srcBuf.capacity() val dstCapacity = dstBuf.capacity() for (row in 0 until height) { diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt index 2874527e..ba39a0f0 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt @@ -1,9 +1,6 @@ package io.github.kdroidfilter.composemediaplayer.linux -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap @@ -11,945 +8,873 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Logger.Companion.setMinSeverity +import co.touchlab.kermit.Severity import io.github.kdroidfilter.composemediaplayer.InitialPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError import io.github.kdroidfilter.composemediaplayer.VideoPlayerState -import io.github.kdroidfilter.composemediaplayer.util.DEFAULT_ASPECT_RATIO import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile -import org.freedesktop.gstreamer.Bin -import org.freedesktop.gstreamer.Bus -import org.freedesktop.gstreamer.Caps -import org.freedesktop.gstreamer.Element -import org.freedesktop.gstreamer.ElementFactory -import org.freedesktop.gstreamer.FlowReturn -import org.freedesktop.gstreamer.Format -import org.freedesktop.gstreamer.GhostPad -import org.freedesktop.gstreamer.Sample -import org.freedesktop.gstreamer.State -import org.freedesktop.gstreamer.elements.AppSink -import org.freedesktop.gstreamer.elements.PlayBin -import org.freedesktop.gstreamer.event.SeekFlags -import org.freedesktop.gstreamer.event.SeekType -import org.freedesktop.gstreamer.message.MessageType -import com.sun.jna.Pointer +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType import org.jetbrains.skia.ImageInfo -import java.awt.EventQueue import java.io.File -import java.net.URI -import java.nio.ByteBuffer -import java.util.EnumSet -import javax.swing.Timer +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import kotlin.math.abs -import kotlin.math.pow +import kotlin.math.log10 + +internal val linuxLogger = Logger.withTag("LinuxVideoPlayerState") + .apply { setMinSeverity(Severity.Warn) } /** - * LinuxVideoPlayerState serves as the Linux-specific implementation for - * a video player using GStreamer. + * LinuxVideoPlayerState — JNI-based implementation using a native C GStreamer player. * - * To dynamically change the subtitle source, the pipeline is set to READY, - * the source is updated, and then the pipeline is set back to PLAYING. - * A Timer performs a slight seek to reposition exactly at the saved position. + * Architecture mirrors MacVideoPlayerState: coroutine-driven polling of the native + * layer for frames, position, audio levels, and end-of-playback detection. */ @Stable class LinuxVideoPlayerState : VideoPlayerState { - companion object { - // Flag to enable text subtitles (GST_PLAY_FLAG_TEXT) - const val GST_PLAY_FLAG_TEXT = 1 shl 2 - } - - init { - GStreamerInit.init() - } - - // Use instance-specific unique identifiers for GStreamer elements - private val instanceId = System.nanoTime().toString() - private val playbin = PlayBin("playbin-$instanceId") - private val videoSink = ElementFactory.make("appsink", "videosink-$instanceId") as AppSink - private val sliderTimer = Timer(50, null) - - // ---- Internal states ---- - private var _currentFrame by mutableStateOf(null) - val currentFrame: ImageBitmap? - get() = _currentFrame + // Native player pointer (AtomicLong for lock-free reads from the frame hot path) + private val playerPtrAtomic = AtomicLong(0L) + private val playerPtr: Long get() = playerPtrAtomic.get() - private var frameWidth = 0 - private var frameHeight = 0 + // Serial dispatcher for frame processing + private val frameDispatcher = Dispatchers.Default.limitedParallelism(1) + private val _currentFrameState = MutableStateFlow(null) + internal val currentFrameState: State = mutableStateOf(null) - // Double-buffering for zero-copy frame rendering + // Double-buffered Skia bitmaps + private var skiaBitmapWidth: Int = 0 + private var skiaBitmapHeight: Int = 0 private var skiaBitmapA: Bitmap? = null private var skiaBitmapB: Bitmap? = null private var nextSkiaBitmapA: Boolean = true - private var lastFrameHash: Int = Int.MIN_VALUE - private var bufferingPercent by mutableStateOf(100) - private var isUserPaused by mutableStateOf(false) - private var hasReceivedFirstFrame by mutableStateOf(false) + // Audio levels + private val _leftLevel = mutableStateOf(0.0f) + private val _rightLevel = mutableStateOf(0.0f) + override val leftLevel: Float get() = _leftLevel.value + override val rightLevel: Float get() = _rightLevel.value + + // Surface display size (pixels) for output scaling + private var surfaceWidth = 0 + private var surfaceHeight = 0 + private val isResizing = AtomicBoolean(false) + private var resizeJob: Job? = null + + // Background worker scopes and jobs + private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var playerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var frameUpdateJob: Job? = null + private var bufferingCheckJob: Job? = null + private var uiUpdateJob: Job? = null + + // State tracking + private var lastFrameUpdateTime: Long = 0 + private var seekInProgress = false + private var targetSeekTime: Double? = null + + // Frame rate from native layer + private var captureFrameRate: Float = 0.0f + + // UI State + override var hasMedia: Boolean by mutableStateOf(false) + override var isPlaying: Boolean by mutableStateOf(false) + override var sliderPos: Float by mutableStateOf(0.0f) + override var userDragging: Boolean by mutableStateOf(false) + override var loop: Boolean by mutableStateOf(false) + override var isLoading: Boolean by mutableStateOf(false) + override var error: VideoPlayerError? by mutableStateOf(null) + override var subtitlesEnabled: Boolean by mutableStateOf(false) + override var currentSubtitleTrack: SubtitleTrack? by mutableStateOf(null) + override val availableSubtitleTracks: MutableList = mutableListOf() + override var subtitleTextStyle: TextStyle by mutableStateOf( + TextStyle( + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center + ) + ) + override var subtitleBackgroundColor: Color by mutableStateOf(Color.Black.copy(alpha = 0.5f)) + override val metadata: VideoMetadata = VideoMetadata() + override var isFullscreen: Boolean by mutableStateOf(false) + private var lastUri: String? = null - private var _sliderPos by mutableStateOf(0f) - override var sliderPos: Float - get() = _sliderPos - set(value) { - _sliderPos = value + private val _positionText = mutableStateOf("00:00") + override val positionText: String get() = _positionText.value + + private val _durationText = mutableStateOf("00:00") + override val durationText: String get() = _durationText.value + + override val currentTime: Double + get() = runBlocking { + if (hasMedia) getPositionSafely() else 0.0 } - // This variable will allow us to handle a potential delay before buffering is signaled - private var _isSeeking by mutableStateOf(false) - private var targetSeekPos: Float = 0f + private val _aspectRatio = mutableStateOf(16f / 9f) + override val aspectRatio: Float get() = _aspectRatio.value - private var _userDragging by mutableStateOf(false) - override var userDragging: Boolean - get() = _userDragging + // Volume + private val _volumeState = mutableStateOf(1.0f) + override var volume: Float + get() = _volumeState.value set(value) { - _userDragging = value - - // If user just finished dragging and we have a pending playback speed update, apply it now - if (!value && pendingPlaybackSpeedUpdate) { - applyPlaybackSpeed() + val newValue = value.coerceIn(0f, 1f) + if (_volumeState.value != newValue) { + _volumeState.value = newValue + ioScope.launch { applyVolume() } } } - private var _loop by mutableStateOf(false) - override var loop: Boolean - get() = _loop + // Playback speed + private val _playbackSpeedState = mutableStateOf(1.0f) + override var playbackSpeed: Float + get() = _playbackSpeedState.value set(value) { - _loop = value + val newValue = value.coerceIn(0.5f, 2.0f) + if (_playbackSpeedState.value != newValue) { + _playbackSpeedState.value = newValue + ioScope.launch { applyPlaybackSpeed() } + } } - private var _playbackSpeed by mutableStateOf(1f) - private var pendingPlaybackSpeedUpdate = false - private var speedUpdateTimer: Timer? = null - private val PLAYBACK_SPEED_DEBOUNCE_MS = 200 - - override var playbackSpeed: Float - get() = _playbackSpeed - set(value) { - val newSpeed = value.coerceIn(0.5f, 2.0f) - // Update the UI immediately - _playbackSpeed = newSpeed - - if (hasMedia) { - // If we're dragging, defer the actual seek operation - if (userDragging) { - pendingPlaybackSpeedUpdate = true - return - } + private val updateInterval: Long + get() = if (captureFrameRate > 0) { + (1000.0f / captureFrameRate).toLong() + } else { + 33L // ~30fps default + } - // Cancel any pending timer - speedUpdateTimer?.stop() + private val bufferingCheckInterval = 200L + private val bufferingTimeoutThreshold = 500L - // Apply the speed change immediately for UI feedback - // but debounce the actual GStreamer operation - pendingPlaybackSpeedUpdate = true + init { + linuxLogger.d { "Initializing Linux video player (JNI)" } + ioScope.launch { + initPlayer() + startUIUpdateJob() + } + } - // Create a new timer for debouncing - speedUpdateTimer = Timer(PLAYBACK_SPEED_DEBOUNCE_MS, { - if (pendingPlaybackSpeedUpdate) { - applyPlaybackSpeed() - } - }) - speedUpdateTimer?.isRepeats = false - speedUpdateTimer?.start() + @OptIn(FlowPreview::class) + private fun startUIUpdateJob() { + uiUpdateJob?.cancel() + uiUpdateJob = ioScope.launch { + _currentFrameState.debounce(1).collect { newFrame -> + ensureActive() + withContext(Dispatchers.Main) { + (currentFrameState as MutableState).value = newFrame + } } } + } - private fun applyPlaybackSpeed() { - pendingPlaybackSpeedUpdate = false + private suspend fun initPlayer() = ioScope.launch { + linuxLogger.d { "initPlayer() - Creating native player" } try { - // Get current position - val currentPosition = playbin.queryPosition(Format.TIME) - - // Perform a seek operation with the new playback speed - // This is the proper way to change playback speed in GStreamer - playbin.seek( - _playbackSpeed.toDouble(), // Rate (speed multiplier) - Format.TIME, // Format - EnumSet.of(SeekFlags.FLUSH, SeekFlags.ACCURATE), // Flags - SeekType.SET, // Start seek type - currentPosition, // Start position - SeekType.NONE, // Stop seek type - -1L // Stop position (not used) - ) + val ptr = SharedVideoPlayer.nCreatePlayer() + if (ptr != 0L) { + playerPtrAtomic.set(ptr) + linuxLogger.d { "Native player created successfully" } + applyVolume() + applyPlaybackSpeed() + } else { + linuxLogger.e { "Failed to create native player" } + withContext(Dispatchers.Main) { + error = VideoPlayerError.UnknownError("Failed to create native player") + } + } } catch (e: Exception) { - e.printStackTrace() - // Don't revert the speed value as it would cause UI inconsistency - } + if (e is CancellationException) throw e + linuxLogger.e { "Exception in initPlayer: ${e.message}" } + withContext(Dispatchers.Main) { + error = VideoPlayerError.UnknownError("Failed to initialize player: ${e.message}") + } } - - private var _volume by mutableStateOf(1f) - override var volume: Float - get() = _volume - set(value) { - _volume = value.coerceIn(0f..1f) - playbin.set("volume", _volume.toDouble()) + }.join() + + private fun checkExistsIfLocalFile(uri: String): Boolean { + 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 } + } - private var _leftLevel by mutableStateOf(0f) - override val leftLevel: Float - get() = _leftLevel - - private var _rightLevel by mutableStateOf(0f) - override val rightLevel: Float - get() = _rightLevel - - private var _positionText by mutableStateOf("00:00") - override val positionText: String - get() = _positionText - - private var _durationText by mutableStateOf("00:00") - override val durationText: String - get() = _durationText - - override val currentTime: Double - get() = if (hasMedia) playbin.queryPosition(Format.TIME) / 1_000_000_000.0 else 0.0 - - private var _isLoading by mutableStateOf(false) - override val isLoading: Boolean - get() = _isLoading - - private var _hasMedia by mutableStateOf(false) - override val hasMedia: Boolean - get() = _hasMedia - - - private var _isPlaying by mutableStateOf(false) - override val isPlaying: Boolean - get() = _isPlaying - - private var _error by mutableStateOf(null) - override val error: VideoPlayerError? - get() = _error + override fun openUri(uri: String, initializeplayerState: InitialPlayerState) { + linuxLogger.d { "openUri() - Opening URI: $uri" } + lastUri = uri - private var _isFullscreen by mutableStateOf(false) - override var isFullscreen: Boolean - get() = _isFullscreen - set(value) { - _isFullscreen = value + if (!checkExistsIfLocalFile(uri)) { + linuxLogger.e { "File does not exist: $uri" } + setPlayerError(VideoPlayerError.SourceError("File not found: $uri")) + return } - override val metadata: VideoMetadata = VideoMetadata() + ioScope.launch { + withContext(Dispatchers.Main) { + isLoading = true + error = null + playbackSpeed = 1.0f + } - override var subtitlesEnabled: Boolean = false - override var currentSubtitleTrack: SubtitleTrack? = null - override val availableSubtitleTracks = mutableListOf() - override var subtitleTextStyle: TextStyle = TextStyle( - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Normal, - textAlign = TextAlign.Center - ) - override var subtitleBackgroundColor: Color = Color.Black.copy(alpha = 0.5f) + try { + if (hasMedia) { + cleanupCurrentPlayback() + } - // ---- Aspect ratio management ---- - private var lastAspectRatioUpdateTime: Long = 0 - private val ASPECT_RATIO_DEBOUNCE_MS = 500 - private var _aspectRatio by mutableStateOf(DEFAULT_ASPECT_RATIO) - override val aspectRatio: Float - get() = _aspectRatio + ensurePlayerInitialized() - init { - // GStreamer configuration - val audiobin = Bin("audiobin-$instanceId") - - // Create a scaletempo element for pitch-corrected playback speed - val scaletempo = ElementFactory.make("scaletempo", "scaletempo-$instanceId") - - // Create the level element for volume monitoring - val levelElement = ElementFactory.make("level", "level-$instanceId") - - // Add elements to audiobin - audiobin.addMany(scaletempo, levelElement) - - // Link elements in sequence: scaletempo -> level - Element.linkMany(scaletempo, levelElement) - - // Create sink and source ghost pads for the bin - val sinkPad = scaletempo.getStaticPad("sink") - val srcPad = levelElement.getStaticPad("src") - - audiobin.addPad(GhostPad("sink", sinkPad)) - audiobin.addPad(GhostPad("src", srcPad)) - - // Set the audiobin as the audio filter for playbin - playbin.set("audio-filter", audiobin) - - // Configuration of the AppSink for video - // Requesting RGBA (R, G, B, A) without additional conversion. - val caps = Caps.fromString("video/x-raw,format=RGBA") - videoSink.caps = caps - videoSink.set("emit-signals", true) - videoSink.connect(AppSink.NEW_SAMPLE { appSink -> - val sample = appSink.pullSample() - if (sample != null) { - processSample(sample) - sample.dispose() - } - FlowReturn.OK - }) - playbin.setVideoSink(videoSink) - - // ---- GStreamer bus handling ---- - - // End of stream - playbin.bus.connect(Bus.EOS { - EventQueue.invokeLater { - if (loop) { - // Restart from beginning if loop = true - playbin.seekSimple(Format.TIME, EnumSet.of(SeekFlags.FLUSH), 0) - } else { - stop() - } - _isPlaying = loop - } - }) - - // Errors - playbin.bus.connect(Bus.ERROR { source, code, message -> - EventQueue.invokeLater { - _error = when { - message.contains("codec", ignoreCase = true) || - message.contains("decode", ignoreCase = true) -> - VideoPlayerError.CodecError(message) - - message.contains("network", ignoreCase = true) || - message.contains("connection", ignoreCase = true) || - message.contains("dns", ignoreCase = true) || - message.contains("http", ignoreCase = true) -> - VideoPlayerError.NetworkError(message) - - message.contains("source", ignoreCase = true) || - message.contains("uri", ignoreCase = true) || - message.contains("resource", ignoreCase = true) -> - VideoPlayerError.SourceError(message) - - else -> - VideoPlayerError.UnknownError(message) - } - stop() - } - }) + val result = openMediaUri(uri) - // Buffering - playbin.bus.connect(Bus.BUFFERING { source, percent -> - EventQueue.invokeLater { - bufferingPercent = percent - // When reaching 100%, we consider that any seek has finished - if (percent == 100) { - _isSeeking = false - } - updateLoadingState() - } - }) - - // Pipeline state change - playbin.bus.connect { source, old, current, pending -> - EventQueue.invokeLater { - when (current) { - State.PLAYING -> { - _isPlaying = true - isUserPaused = false - updateLoadingState() - updateAspectRatio() - } + if (result) { + // Update frame rate from native layer + updateFrameRateInfo() + updateMetadata() - State.PAUSED -> { - _isPlaying = false - updateLoadingState() + if (surfaceWidth > 0 && surfaceHeight > 0) { + applyOutputScaling() } - State.READY -> { - _isPlaying = false - updateLoadingState() + withContext(Dispatchers.Main) { + hasMedia = true + isLoading = false + isPlaying = initializeplayerState == InitialPlayerState.PLAY } - else -> { - _isPlaying = false - updateLoadingState() - } - } - } - } + startFrameUpdates() + updateFrameAsync() + startBufferingCheck() - // TAG (metadata) - playbin.bus.connect(Bus.TAG { source, tagList -> - if (tagList != null) { - EventQueue.invokeLater { - try { - // Extract metadata from TagList - try { - // Try to extract title - val title = tagList.getString("title", 0) - if (title != null) { - metadata.title = title - } - } catch (_: Exception) { - // Ignore errors when getting title - } - - try { - // Try to extract video bitrate first - val videoBitrate = tagList.getString("video-bitrate", 0) - if (videoBitrate != null) { - try { - // The bitrate is already in bps, no need to convert - metadata.bitrate = videoBitrate.toLong() - } catch (_: NumberFormatException) { - // Ignore if the string can't be converted to a long - } - } else { - // Fallback to generic bitrate if video-specific one is not available - val bitrate = tagList.getString("bitrate", 0) - if (bitrate != null) { - try { - // The bitrate is already in bps, no need to convert - metadata.bitrate = bitrate.toLong() - } catch (_: NumberFormatException) { - // Ignore if the string can't be converted to a long - } - } - } - } catch (_: Exception) { - // Ignore errors when getting bitrate - } - - try { - // Try to extract MIME type from container format - val containerFormat = tagList.getString("container-format", 0) - if (containerFormat != null) { - metadata.mimeType = containerFormat - } else { - // Try audio codec as fallback - val audioCodec = tagList.getString("audio-codec", 0) - if (audioCodec != null) { - metadata.mimeType = audioCodec - } else { - // Try video codec as fallback - val videoCodec = tagList.getString("video-codec", 0) - if (videoCodec != null) { - metadata.mimeType = videoCodec - } - } - } - } catch (_: Exception) { - // Ignore errors when getting MIME type - } - - try { - // Try to extract audio channels - val audioChannels = tagList.getString("audio-channels", 0) - if (audioChannels != null) { - try { - metadata.audioChannels = audioChannels.toInt() - } catch (_: NumberFormatException) { - // Ignore if the string can't be converted to an integer - } - } - } catch (_: Exception) { - // Ignore errors when getting audio channels - } - - // We'll also update metadata from the pipeline - updateVideoMetadata() - } catch (e: Exception) { - e.printStackTrace() + if (isPlaying) { + playInBackground() } - } - } - }) - - // Measuring audio level (via the "level" element) - playbin.bus.connect("element") { _, message -> - if (message.source == levelElement) { - val struct = message.structure - if (struct != null && struct.hasField("peak")) { - val peaks = struct.getDoubles("peak") - if (peaks.isNotEmpty() && isPlaying) { - for (i in peaks.indices) { - peaks[i] = 10.0.pow(peaks[i] / 20.0) - } - val l = if (peaks.isNotEmpty()) peaks[0] else 0.0 - val r = if (peaks.size > 1) peaks[1] else l - EventQueue.invokeLater { - _leftLevel = (l.coerceIn(0.0, 1.0) * 100f).toFloat() - _rightLevel = (r.coerceIn(0.0, 1.0) * 100f).toFloat() - } - } else { - EventQueue.invokeLater { - _leftLevel = 0f - _rightLevel = 0f - } + } else { + linuxLogger.e { "Failed to open URI" } + withContext(Dispatchers.Main) { + isLoading = false + error = VideoPlayerError.SourceError("Failed to open media source") } } + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "openUri() - Exception: ${e.message}" } + handleError(e) } } + } - // Also monitoring the end of async transitions (e.g., after a seek) - playbin.bus.connect("async-done") { _, message -> - if (message.type == MessageType.ASYNC_DONE) { - EventQueue.invokeLater { - _isSeeking = false - updateLoadingState() + override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { + openUri(file.file.path, initializeplayerState) + } - // Update metadata after async operations (like seeking) complete - updateVideoMetadata() - } - } + private suspend fun cleanupCurrentPlayback() { + pauseInBackground() + stopFrameUpdates() + stopBufferingCheck() + + val ptrToDispose = withContext(frameDispatcher) { + playerPtrAtomic.getAndSet(0L) } - // Timer for the slider position and duration - sliderTimer.addActionListener { - if (!userDragging) { - val dur = playbin.queryDuration(Format.TIME) - val pos = playbin.queryPosition(Format.TIME) - if (dur > 0) { - val relPos = pos.toDouble() / dur.toDouble() - val currentSliderPos = (relPos * 1000.0).toFloat() + if (ptrToDispose != 0L) { + try { + SharedVideoPlayer.nDisposePlayer(ptrToDispose) + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "Error disposing player: ${e.message}" } + } + } + } - if (targetSeekPos > 0f) { - if (abs(targetSeekPos - currentSliderPos) < 1f) { - _sliderPos = currentSliderPos - targetSeekPos = 0f - } - } else { - _sliderPos = currentSliderPos - } + private suspend fun ensurePlayerInitialized() { + if (!playerScope.isActive) { + playerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + } - if (pos > 0) { - EventQueue.invokeLater { - _positionText = formatTime(pos, true) - _durationText = formatTime(dur, true) - } - } + if (playerPtr == 0L) { + val ptr = SharedVideoPlayer.nCreatePlayer() + if (ptr != 0L) { + if (!playerPtrAtomic.compareAndSet(0L, ptr)) { + SharedVideoPlayer.nDisposePlayer(ptr) + } else { + applyVolume() + applyPlaybackSpeed() } + } else { + throw IllegalStateException("Failed to create native player") } } - sliderTimer.start() } - // ---- Subtitle management ---- - override fun selectSubtitleTrack(track: SubtitleTrack?) { - currentSubtitleTrack = track - subtitlesEnabled = track != null + private suspend fun openMediaUri(uri: String): Boolean { + val ptr = playerPtr + if (ptr == 0L) return false - // We're not using GStreamer's native subtitle rendering anymore - // Instead, we're using Compose-based subtitles - // So we don't need to set the suburi or enable the GST_PLAY_FLAG_TEXT flag + if (!checkExistsIfLocalFile(uri)) { + setPlayerError(VideoPlayerError.SourceError("File not found: $uri")) + return false + } - // Just for backward compatibility, we'll disable any existing subtitles in GStreamer - try { - // Disable native subtitles in GStreamer - playbin.set("suburi", "") - val currentFlags = playbin.get("flags") as Int - playbin.set("flags", currentFlags and GST_PLAY_FLAG_TEXT.inv()) - } catch (_: Exception) { - // Ignore errors, as we're not using GStreamer's subtitle rendering anyway + return try { + SharedVideoPlayer.nOpenUri(ptr, uri) + pollDimensionsUntilReady(ptr) + updateMetadata() + true + } catch (e: Exception) { + linuxLogger.e { "Failed to open URI: ${e.message}" } + setPlayerError(VideoPlayerError.SourceError("Error opening media: ${e.message}")) + false } } - override fun disableSubtitles() { - currentSubtitleTrack = null - subtitlesEnabled = false - - // We're not using GStreamer's native subtitle rendering anymore - // Instead, we're using Compose-based subtitles - // So we don't need to disable the GST_PLAY_FLAG_TEXT flag + private suspend fun pollDimensionsUntilReady(ptr: Long, maxAttempts: Int = 20) { + for (attempt in 1..maxAttempts) { + val width = SharedVideoPlayer.nGetFrameWidth(ptr) + val height = SharedVideoPlayer.nGetFrameHeight(ptr) + if (width > 0 && height > 0) { + linuxLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" } + return + } + linuxLogger.d { "Dimensions not ready yet (attempt $attempt/$maxAttempts)" } + delay(250) + } + linuxLogger.e { "Unable to retrieve valid dimensions after $maxAttempts attempts" } + } - // Just for backward compatibility, we'll disable any existing subtitles in GStreamer + private suspend fun updateFrameRateInfo() { + val ptr = playerPtr + if (ptr == 0L) return try { - // Disable native subtitles in GStreamer - playbin.set("suburi", "") - val currentFlags = playbin.get("flags") as Int - playbin.set("flags", currentFlags and GST_PLAY_FLAG_TEXT.inv()) - } catch (_: Exception) { - // Ignore errors, as we're not using GStreamer's subtitle rendering anyway + captureFrameRate = SharedVideoPlayer.nGetFrameRate(ptr) + linuxLogger.d { "Frame rate: $captureFrameRate" } + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "Error updating frame rate: ${e.message}" } } } - // ---- Aspect ratio management ---- - private fun updateAspectRatio() { - val currentTime = System.currentTimeMillis() - if (currentTime - lastAspectRatioUpdateTime < ASPECT_RATIO_DEBOUNCE_MS) { - return - } - lastAspectRatioUpdateTime = currentTime + private suspend fun updateMetadata() { + val ptr = playerPtr + if (ptr == 0L) return try { - val videoSinkElement = playbin.get("video-sink") as? Element - val sinkPad = videoSinkElement?.getStaticPad("sink") - val caps = sinkPad?.currentCaps - val structure = caps?.getStructure(0) - - if (structure != null) { - val width = structure.getInteger("width") - val height = structure.getInteger("height") - - if (width > 0 && height > 0) { - val calculatedRatio = width.toFloat() / height.toFloat() - if (calculatedRatio != _aspectRatio) { - EventQueue.invokeLater { - _aspectRatio = if (calculatedRatio > 0) calculatedRatio else 16f / 9f - } - } - } + val width = SharedVideoPlayer.nGetFrameWidth(ptr) + val height = SharedVideoPlayer.nGetFrameHeight(ptr) + val duration = SharedVideoPlayer.nGetVideoDuration(ptr).toLong() + val frameRate = SharedVideoPlayer.nGetFrameRate(ptr) + val newAspectRatio = if (width > 0 && height > 0) { + width.toFloat() / height.toFloat() + } else { + _aspectRatio.value + } + + 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) { + metadata.duration = duration + metadata.width = width + metadata.height = height + metadata.frameRate = frameRate + metadata.title = title + metadata.bitrate = bitrate + metadata.mimeType = mimeType + metadata.audioChannels = if (audioChannels == 0) null else audioChannels + metadata.audioSampleRate = if (audioSampleRate == 0) null else audioSampleRate + _aspectRatio.value = newAspectRatio } } catch (e: Exception) { - e.printStackTrace() - _aspectRatio = 16f / 9f + if (e is CancellationException) throw e + linuxLogger.e { "Error updating metadata: ${e.message}" } } } - private fun updateLoadingState() { - _isLoading = when { - bufferingPercent < 100 -> true - _isSeeking -> true - isUserPaused -> false - _hasMedia && !hasReceivedFirstFrame -> true - else -> false + // --- Frame update loop --- + + private fun startFrameUpdates() { + stopFrameUpdates() + frameUpdateJob = ioScope.launch { + while (isActive) { + ensureActive() + updateFrameAsync() + if (!userDragging) { + updatePositionAsync() + updateAudioLevelsAsync() + } + delay(updateInterval) + } } } - /** - * Updates the video metadata from the pipeline. - * This extracts information like width, height, duration, and frame rate. - */ - private fun updateVideoMetadata() { - try { - // Get duration - val duration = playbin.queryDuration(Format.TIME) - if (duration > 0) { - metadata.duration = duration + private fun stopFrameUpdates() { + frameUpdateJob?.cancel() + frameUpdateJob = null + } + + private fun startBufferingCheck() { + stopBufferingCheck() + bufferingCheckJob = ioScope.launch { + while (isActive) { + ensureActive() + checkBufferingState() + delay(bufferingCheckInterval) } + } + } - // Get width and height from video sink - val videoSinkElement = playbin.get("video-sink") as? Element - val sinkPad = videoSinkElement?.getStaticPad("sink") - val caps = sinkPad?.currentCaps - val structure = caps?.getStructure(0) + private suspend fun checkBufferingState() { + if (isPlaying && !isLoading) { + val timeSinceLastFrame = System.currentTimeMillis() - lastFrameUpdateTime + if (timeSinceLastFrame > bufferingTimeoutThreshold) { + withContext(Dispatchers.Main) { isLoading = true } + } + } + } - if (structure != null) { - try { - val width = structure.getInteger("width") - val height = structure.getInteger("height") + private fun stopBufferingCheck() { + bufferingCheckJob?.cancel() + bufferingCheckJob = null + } - if (width > 0 && height > 0) { - metadata.width = width - metadata.height = height + private suspend fun updateFrameAsync() { + withContext(frameDispatcher) { + try { + val ptr = playerPtr + if (ptr == 0L) return@withContext + + val width = SharedVideoPlayer.nGetFrameWidth(ptr) + val height = SharedVideoPlayer.nGetFrameHeight(ptr) + if (width <= 0 || height <= 0) return@withContext + + val frameAddress = SharedVideoPlayer.nGetLatestFrameAddress(ptr) + if (frameAddress == 0L) return@withContext + + val pixelCount = width * height + val frameSizeBytes = pixelCount.toLong() * 4L + var framePublished = false + + withContext(Dispatchers.Default) { + val srcBuf = SharedVideoPlayer.nWrapPointer(frameAddress, frameSizeBytes) + ?: return@withContext + + // Allocate/reuse double-buffered bitmaps + 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 to get frame rate if available - if (structure.hasField("framerate")) { - val fraction = structure.getFraction("framerate") - if (fraction != null && fraction.denominator > 0) { - val frameRate = fraction.numerator.toFloat() / fraction.denominator.toFloat() - metadata.frameRate = frameRate - } + val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!! + nextSkiaBitmapA = !nextSkiaBitmapA + + val pixmap = targetBitmap.peekPixels() ?: return@withContext + val pixelsAddr = pixmap.addr + if (pixelsAddr == 0L) return@withContext + + // Native-to-native copy: frame buffer -> Skia bitmap pixels + srcBuf.rewind() + val destRowBytes = pixmap.rowBytes.toInt() + val destSizeBytes = destRowBytes.toLong() * height.toLong() + val destBuf = SharedVideoPlayer.nWrapPointer(pixelsAddr, destSizeBytes) + ?: return@withContext + copyBgraFrame(srcBuf, destBuf, width, height, destRowBytes) + + _currentFrameState.value = targetBitmap.asComposeImageBitmap() + framePublished = true + } + + if (framePublished) { + lastFrameUpdateTime = System.currentTimeMillis() + if (isLoading && !seekInProgress) { + withContext(Dispatchers.Main) { isLoading = false } } - } catch (_: Exception) { - // Ignore errors when getting specific fields } + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "updateFrameAsync() - Exception: ${e.message}" } } + } + } - // Get audio channels and sample rate if available - // On Linux, we need to use a different approach to get audio metadata - try { - // Try to get audio information from the audio-filter element (level) - val levelElement = playbin.get("audio-filter") as? Element - if (levelElement != null) { - val sinkPad = levelElement.getStaticPad("sink") - val audioCaps = sinkPad?.currentCaps - val audioStructure = audioCaps?.getStructure(0) - - if (audioStructure != null) { - if (audioStructure.hasField("channels")) { - val channels = audioStructure.getInteger("channels") - metadata.audioChannels = channels - } - - if (audioStructure.hasField("rate")) { - val rate = audioStructure.getInteger("rate") - metadata.audioSampleRate = rate - } - } + private suspend fun updateAudioLevelsAsync() { + if (!hasMedia) return + try { + val ptr = playerPtr + if (ptr != 0L) { + val newLeft = SharedVideoPlayer.nGetLeftAudioLevel(ptr) + val newRight = SharedVideoPlayer.nGetRightAudioLevel(ptr) + + fun convertToPercentage(level: Float): Float { + if (level <= 0f) return 0f + val db = 20 * log10(level) + val normalized = ((db + 60) / 60).coerceIn(0f, 1f) + return normalized * 100f } - // If we couldn't get the info from audio-filter, try the traditional approach - if (metadata.audioChannels == null || metadata.audioSampleRate == null) { - val audioSinkPad = playbin.getStaticPad("audio_sink") - val audioCaps = audioSinkPad?.currentCaps - val audioStructure = audioCaps?.getStructure(0) - - if (audioStructure != null) { - if (audioStructure.hasField("channels") && metadata.audioChannels == null) { - val channels = audioStructure.getInteger("channels") - metadata.audioChannels = channels - } - - if (audioStructure.hasField("rate") && metadata.audioSampleRate == null) { - val rate = audioStructure.getInteger("rate") - metadata.audioSampleRate = rate - } - } + withContext(Dispatchers.Main) { + _leftLevel.value = convertToPercentage(newLeft) + _rightLevel.value = convertToPercentage(newRight) } - } catch (e: Exception) { - // Ignore errors when getting specific fields - e.printStackTrace() } - } catch (_: Exception) { - // Ignore general errors + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "Error updating audio levels: ${e.message}" } } } - // ---- Controls ---- - override fun openUri(uri: String, initializeplayerState: InitialPlayerState) { - stop() - clearError() - _isLoading = true - _hasMedia = false - hasReceivedFirstFrame = false - playbackSpeed = 1.0f + private suspend fun updatePositionAsync() { + if (!hasMedia || userDragging) return try { - val uriObj = if (uri.startsWith("http://") || uri.startsWith("https://")) { - URI(uri) - } else { - File(uri).toURI() - } - playbin.setURI(uriObj) - _hasMedia = true - - // Reset metadata for the new media - metadata.title = null - metadata.duration = null - metadata.width = null - metadata.height = null - metadata.bitrate = null - metadata.frameRate = null - metadata.mimeType = null - metadata.audioChannels = null - metadata.audioSampleRate = null - - // Control initial playback state based on the parameter - if (initializeplayerState == InitialPlayerState.PLAY) { - play() + val duration = getDurationSafely() + if (duration <= 0) return + + val current = getPositionSafely() + + withContext(Dispatchers.Main) { + _positionText.value = formatTime(current) + _durationText.value = formatTime(duration) + } + + if (seekInProgress && targetSeekTime != null) { + if (abs(current - targetSeekTime!!) < 0.3) { + seekInProgress = false + targetSeekTime = null + withContext(Dispatchers.Main) { isLoading = false } + } } else { - // Initialize player but don't start playback - _hasMedia = true - _isPlaying = false - isUserPaused = true - updateVideoMetadata() - updateLoadingState() + val newSliderPos = (current / duration * 1000).toFloat().coerceIn(0f, 1000f) + withContext(Dispatchers.Main) { sliderPos = newSliderPos } } + + checkLoopingAsync(current, duration) } catch (e: Exception) { - _error = VideoPlayerError.SourceError("Unable to open URI: ${e.message}") - _isLoading = false - _isPlaying = false - _hasMedia = false - e.printStackTrace() + if (e is CancellationException) throw e + linuxLogger.e { "Error in updatePositionAsync: ${e.message}" } } } - override fun openFile( - file: PlatformFile, - initializeplayerState: InitialPlayerState - ) { - openUri(file.file.path, initializeplayerState) + private suspend fun checkLoopingAsync(current: Double, duration: Double) { + val ptr = playerPtr + val ended = ptr != 0L && SharedVideoPlayer.nConsumeDidPlayToEnd(ptr) + if (!ended && (duration <= 0 || current < duration - 0.5)) return + + if (loop) { + seekToAsync(0f) + } else { + withContext(Dispatchers.Main) { isPlaying = false } + pauseInBackground() + } } + // --- Playback controls --- + override fun play() { + ioScope.launch { + if (!hasMedia && lastUri != null) { + openUri(lastUri!!) + } else if (hasMedia) { + playInBackground() + } else { + withContext(Dispatchers.Main) { + isPlaying = false + isLoading = false + } + } + } + } + + private suspend fun playInBackground() { + val ptr = playerPtr + if (ptr == 0L) return try { - playbin.play() - playbin.set("volume", volume.toDouble()) - _hasMedia = true - _isPlaying = true - isUserPaused = false - updateLoadingState() - - // Update metadata when starting playback - updateVideoMetadata() + SharedVideoPlayer.nPlay(ptr) + withContext(Dispatchers.Main) { isPlaying = true } + startFrameUpdates() + startBufferingCheck() } catch (e: Exception) { - _error = VideoPlayerError.UnknownError("Playback failed: ${e.message}") - _isPlaying = false + if (e is CancellationException) throw e + linuxLogger.e { "Error in playInBackground: ${e.message}" } + handleError(e) } } override fun pause() { + ioScope.launch { pauseInBackground() } + } + + private suspend fun pauseInBackground() { + val ptr = playerPtr + if (ptr == 0L) return try { - playbin.pause() - _isPlaying = false - isUserPaused = true - updateLoadingState() + SharedVideoPlayer.nPause(ptr) + withContext(Dispatchers.Main) { + isPlaying = false + isLoading = false + } + updateFrameAsync() + stopFrameUpdates() + stopBufferingCheck() } catch (e: Exception) { - _error = VideoPlayerError.UnknownError("Pause failed: ${e.message}") + if (e is CancellationException) throw e + linuxLogger.e { "Error in pauseInBackground: ${e.message}" } } } override fun stop() { - playbin.stop() - _isPlaying = false - _sliderPos = 0f - _positionText = "0:00" - _isLoading = false - isUserPaused = false - bufferingPercent = 100 - _hasMedia = false - _isSeeking = false - hasReceivedFirstFrame = false - _currentFrame = null - lastFrameHash = Int.MIN_VALUE + ioScope.launch { + pauseInBackground() + if (hasMedia) seekToAsync(0f) + withContext(Dispatchers.Main) { + hasMedia = false + isLoading = false + resetState() + } + } } override fun seekTo(value: Float) { - val dur = playbin.queryDuration(Format.TIME) - if (dur > 0) { - // Force the loading and seeking indicator before the operation - _isSeeking = true - _isLoading = true + ioScope.launch { + delay(10) // Coalesce rapid seek events + seekToAsync(value) + } + } + + private suspend fun seekToAsync(value: Float) { + withContext(Dispatchers.Main) { isLoading = true } - _sliderPos = value - targetSeekPos = value + try { + val duration = getDurationSafely() + if (duration <= 0) { + withContext(Dispatchers.Main) { isLoading = false } + return + } - val relPos = value / 1000f - val seekPos = (relPos * dur).toLong() - _positionText = formatTime(seekPos, true) + val seekTime = ((value / 1000f) * duration.toFloat()).coerceIn(0f, duration.toFloat()) - playbin.seekSimple(Format.TIME, EnumSet.of(SeekFlags.FLUSH, SeekFlags.ACCURATE), seekPos) + withContext(Dispatchers.Main) { + seekInProgress = true + targetSeekTime = seekTime.toDouble() + sliderPos = value + } - EventQueue.invokeLater { - _positionText = formatTime(seekPos, true) + lastFrameUpdateTime = System.currentTimeMillis() + + val ptr = playerPtr + if (ptr == 0L) return + SharedVideoPlayer.nSeekTo(ptr, seekTime.toDouble()) + + if (isPlaying) { + SharedVideoPlayer.nPlay(ptr) + delay(10) + updateFrameAsync() + ioScope.launch { + delay(300) + if (seekInProgress) { + seekInProgress = false + targetSeekTime = null + withContext(Dispatchers.Main) { isLoading = false } + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "Error in seekToAsync: ${e.message}" } + withContext(Dispatchers.Main) { + isLoading = false + seekInProgress = false + targetSeekTime = null } } } override fun clearError() { - _error = null + runBlocking { + withContext(Dispatchers.Main) { error = null } + } } - /** - * Toggles the fullscreen state of the video player - */ override fun toggleFullscreen() { - _isFullscreen = !_isFullscreen - } - - // ---- Processing of a video sample ---- - /** - * Zero-copy optimized frame processing using double-buffering and direct memory access. - * - * Optimizations applied: - * 1. Double-buffering: Reuses two Bitmap objects, alternating between them to avoid - * allocating new bitmaps every frame while the UI draws from the previous one. - * 2. Frame hashing: Skips processing if the frame content hasn't changed (identical frames). - * 3. peekPixels(): Direct access to Skia bitmap memory, avoiding intermediate ByteArray allocation. - * 4. Single memory copy: GStreamer buffer → Skia bitmap pixels (true zero-copy beyond this necessary transfer). - * - * Memory flow: GStreamer native buffer → Skia bitmap pixels (1 copy via bulk ByteBuffer.put) - */ - private fun processSample(sample: Sample) { - try { - val caps = sample.caps ?: return - val structure = caps.getStructure(0) ?: return - - val width = structure.getInteger("width") - val height = structure.getInteger("height") + isFullscreen = !isFullscreen + } - if (width <= 0 || height <= 0) return + override fun dispose() { + stopFrameUpdates() + stopBufferingCheck() + uiUpdateJob?.cancel() + playerScope.cancel() - // Handle dimension changes - if (width != frameWidth || height != frameHeight) { - frameWidth = width - frameHeight = height + ioScope.launch { + val ptrToDispose = withContext(frameDispatcher) { + val ptr = playerPtrAtomic.getAndSet(0L) - // Reallocate bitmaps for new dimensions skiaBitmapA?.close() skiaBitmapB?.close() - - val imageInfo = ImageInfo(width, height, ColorType.RGBA_8888, ColorAlphaType.UNPREMUL) - skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) } - skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) } + skiaBitmapA = null + skiaBitmapB = null + skiaBitmapWidth = 0 + skiaBitmapHeight = 0 nextSkiaBitmapA = true - lastFrameHash = Int.MIN_VALUE - updateAspectRatio() + ptr } - val buffer = sample.buffer ?: return - val srcBuffer = buffer.map(false) ?: return - srcBuffer.rewind() + if (ptrToDispose != 0L) { + try { + SharedVideoPlayer.nDisposePlayer(ptrToDispose) + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "Error disposing player: ${e.message}" } + } + } - val pixelCount = width * height + resetState() + } - // Calculate frame hash to detect identical frames - val newHash = calculateFrameHash(srcBuffer, pixelCount) - if (newHash == lastFrameHash) { - buffer.unmap() - return - } - lastFrameHash = newHash + ioScope.cancel() + } - // Select the target bitmap (double-buffering) - val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!! - nextSkiaBitmapA = !nextSkiaBitmapA + // --- Subtitle stubs --- - // Get direct access to bitmap pixels via peekPixels (zero-copy access) - val pixmap = targetBitmap.peekPixels() ?: run { - buffer.unmap() - return + override fun selectSubtitleTrack(track: SubtitleTrack?) { + ioScope.launch { + withContext(Dispatchers.Main) { + currentSubtitleTrack = track + subtitlesEnabled = track != null } + } + } - val pixelsAddr = pixmap.addr - if (pixelsAddr == 0L) { - buffer.unmap() - return + override fun disableSubtitles() { + ioScope.launch { + withContext(Dispatchers.Main) { + subtitlesEnabled = false + currentSubtitleTrack = null } + } + } - // Single memory copy: GStreamer buffer → Skia bitmap - val dstRowBytes = pixmap.rowBytes.toInt() - val dstSizeBytes = dstRowBytes.toLong() * height.toLong() - val dstBuffer = Pointer(pixelsAddr).getByteBuffer(0, dstSizeBytes) + // --- Output scaling --- - srcBuffer.rewind() - copyRgbaFrame(srcBuffer, dstBuffer, width, height, dstRowBytes) + fun onResized(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + if (width == surfaceWidth && height == surfaceHeight) return - // Convert to Compose ImageBitmap - val imageBitmap = targetBitmap.asComposeImageBitmap() + surfaceWidth = width + surfaceHeight = height - // Update on the AWT thread - EventQueue.invokeLater { - _currentFrame = imageBitmap - if (!hasReceivedFirstFrame) { - hasReceivedFirstFrame = true - updateLoadingState() - } + isResizing.set(true) + resizeJob?.cancel() + resizeJob = ioScope.launch { + delay(120) + try { + applyOutputScaling() + } finally { + isResizing.set(false) } + } + } + + 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) + } - buffer.unmap() + // --- Internal helpers --- + + private suspend fun resetState() { + withContext(Dispatchers.Main) { + hasMedia = false + isPlaying = false + isLoading = false + _positionText.value = "00:00" + _durationText.value = "00:00" + _aspectRatio.value = 16f / 9f + error = null + } + _currentFrameState.value = null + } + private fun setPlayerError(error: VideoPlayerError) { + runBlocking { + withContext(Dispatchers.Main) { + isLoading = false + this@LinuxVideoPlayerState.error = error + } + } + } + + private suspend fun handleError(e: Exception) { + withContext(Dispatchers.Main) { + isLoading = false + error = VideoPlayerError.SourceError("Error: ${e.message}") + } + } + + private suspend fun getPositionSafely(): Double { + val ptr = playerPtr + if (ptr == 0L) return 0.0 + return try { + SharedVideoPlayer.nGetCurrentTime(ptr) } catch (e: Exception) { - e.printStackTrace() + if (e is CancellationException) throw e + 0.0 } } - // ---- Release resources ---- - override fun dispose() { - sliderTimer.stop() - speedUpdateTimer?.stop() - playbin.stop() - playbin.dispose() - videoSink.dispose() - - // Clean up double-buffering bitmaps - skiaBitmapA?.close() - skiaBitmapB?.close() - skiaBitmapA = null - skiaBitmapB = null - lastFrameHash = Int.MIN_VALUE - - // Don't call Gst.deinit() here as it would affect all instances - // Each instance should only clean up its own resources + private suspend fun getDurationSafely(): Double { + val ptr = playerPtr + if (ptr == 0L) return 0.0 + return try { + SharedVideoPlayer.nGetVideoDuration(ptr) + } catch (e: Exception) { + if (e is CancellationException) throw e + 0.0 + } + } + + private suspend fun applyVolume() { + val ptr = playerPtr + if (ptr != 0L) try { + SharedVideoPlayer.nSetVolume(ptr, _volumeState.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + + private suspend fun applyPlaybackSpeed() { + val ptr = playerPtr + if (ptr != 0L) try { + SharedVideoPlayer.nSetPlaybackSpeed(ptr, _playbackSpeedState.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + } } } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt index e58abd27..5515d03e 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerSurface.jvm.kt @@ -4,9 +4,12 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 @@ -15,24 +18,15 @@ import io.github.kdroidfilter.composemediaplayer.util.toTimeMs /** - * A composable function that renders a video player surface using GStreamer with offscreen rendering. + * A composable function that renders a video player surface using a native GStreamer + * player via JNI with offscreen rendering. * - * This function creates a video rendering area using a Compose Canvas to draw video frames - * that are rendered offscreen by GStreamer. This approach avoids the rendering issues - * that can occur when using SwingPanel, especially with overlapping UI elements. - * - * @param playerState The state object that encapsulates the GStreamer player logic, - * including playback control, timeline management, and video frames. - * @param modifier An optional `Modifier` for customizing the layout and appearance of the - * composable container. Defaults to an empty `Modifier`. + * @param playerState The state object that encapsulates the native GStreamer player logic. + * @param modifier An optional `Modifier` for customizing the layout and appearance. * @param contentScale Controls how the video content should be scaled inside the surface. - * This affects how the video is displayed when its dimensions don't match - * the surface dimensions. * @param overlay Optional composable content to be displayed on top of the video surface. - * This can be used to add custom controls, information, or any UI elements. * @param isInFullscreenWindow Whether this surface is already being displayed in a fullscreen window. */ - @Composable fun LinuxVideoPlayerSurface( playerState: LinuxVideoPlayerState, @@ -41,51 +35,52 @@ fun LinuxVideoPlayerSurface( overlay: @Composable () -> Unit = {}, 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 if (playerState.hasMedia && (!playerState.isFullscreen || isInFullscreenWindow)) { - Canvas( - modifier = contentScale.toCanvasModifier( - aspectRatio = playerState.aspectRatio, - width = playerState.metadata.width, - height = playerState.metadata.height - ) - ) { - playerState.currentFrame?.let { frame -> + // Force recomposition when currentFrameState changes + val currentFrame by remember(playerState) { playerState.currentFrameState } + + currentFrame?.let { frame -> + Canvas( + modifier = contentScale.toCanvasModifier( + playerState.aspectRatio, + playerState.metadata.width, + playerState.metadata.height + ), + ) { drawScaledImage( - image = frame, - dstSize = IntSize(size.width.toInt(), size.height.toInt()), + image = frame, + dstSize = IntSize(size.width.toInt(), size.height.toInt()), contentScale = contentScale ) } } // Add Compose-based subtitle layer - // Always render the subtitle layer, but let it handle visibility internally - // This ensures it's properly recomposed when subtitles are enabled during playback - val currentTimeMs = (playerState.sliderPos / 1000f * - playerState.durationText.toTimeMs()).toLong() - - // Calculate duration in milliseconds - val durationMs = playerState.durationText.toTimeMs() + if (playerState.subtitlesEnabled && playerState.currentSubtitleTrack != null) { + val currentTimeMs = (playerState.sliderPos / 1000f * + playerState.durationText.toTimeMs()).toLong() + val durationMs = playerState.durationText.toTimeMs() - ComposeSubtitleLayer( - currentTimeMs = currentTimeMs, - durationMs = durationMs, - isPlaying = playerState.isPlaying, - subtitleTrack = playerState.currentSubtitleTrack, - subtitlesEnabled = playerState.subtitlesEnabled, - textStyle = playerState.subtitleTextStyle, - backgroundColor = playerState.subtitleBackgroundColor - ) + ComposeSubtitleLayer( + currentTimeMs = currentTimeMs, + durationMs = durationMs, + isPlaying = playerState.isPlaying, + subtitleTrack = playerState.currentSubtitleTrack, + subtitlesEnabled = playerState.subtitlesEnabled, + textStyle = playerState.subtitleTextStyle, + backgroundColor = playerState.subtitleBackgroundColor + ) + } } // Render the overlay content on top of the video with fillMaxSize modifier - // to ensure it takes the full height of the parent Box Box(modifier = Modifier.fillMaxSize()) { overlay() } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer.kt new file mode 100644 index 00000000..44fe7633 --- /dev/null +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/SharedVideoPlayer.kt @@ -0,0 +1,71 @@ +package io.github.kdroidfilter.composemediaplayer.linux + +import java.io.File +import java.nio.ByteBuffer +import java.nio.file.Files + +/** + * JNI direct mapping to the native Linux GStreamer video player library. + * Handles are opaque Long values (native pointer cast to jlong, 0 = null). + */ +internal object SharedVideoPlayer { + init { + loadNativeLibrary() + } + + private fun loadNativeLibrary() { + val osArch = System.getProperty("os.arch", "").lowercase() + val resourceDir = if (osArch == "aarch64" || osArch == "arm64") "linux-aarch64" else "linux-x86-64" + val libName = "libNativeVideoPlayer.so" + + 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 + @JvmStatic external fun nGetLatestFrameAddress(handle: Long): 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 + @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 + @JvmStatic external fun nGetFrameRate(handle: Long): Float + + // Playback completion + @JvmStatic external fun nConsumeDidPlayToEnd(handle: Long): Boolean +} diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/CurrentPlatform.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/CurrentPlatform.kt new file mode 100644 index 00000000..9cd51da9 --- /dev/null +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/CurrentPlatform.kt @@ -0,0 +1,18 @@ +package io.github.kdroidfilter.composemediaplayer.util + +/** + * Lightweight platform detection using System properties. + * Replaces com.sun.jna.Platform to avoid the JNA dependency. + */ +internal object CurrentPlatform { + enum class OS { WINDOWS, MAC, LINUX } + + val os: OS by lazy { + val name = System.getProperty("os.name", "").lowercase() + when { + name.contains("win") -> OS.WINDOWS + name.contains("mac") || name.contains("darwin") -> OS.MAC + else -> OS.LINUX + } + } +} diff --git a/mediaplayer/src/jvmMain/native/linux/CMakeLists.txt b/mediaplayer/src/jvmMain/native/linux/CMakeLists.txt new file mode 100644 index 00000000..cf49ec6f --- /dev/null +++ b/mediaplayer/src/jvmMain/native/linux/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.15) +project(NativeVideoPlayer C) + +set(CMAKE_C_STANDARD 11) + +# Find GStreamer via pkg-config +find_package(PkgConfig REQUIRED) +pkg_check_modules(GST REQUIRED + gstreamer-1.0 + gstreamer-app-1.0 + gstreamer-video-1.0 +) + +# Find JNI +find_package(JNI REQUIRED) + +# Source files +set(SOURCES + NativeVideoPlayer.c + jni_bridge.c +) + +add_library(NativeVideoPlayer SHARED ${SOURCES}) + +target_include_directories(NativeVideoPlayer PRIVATE + ${GST_INCLUDE_DIRS} + ${JNI_INCLUDE_DIRS} +) + +target_link_libraries(NativeVideoPlayer PRIVATE + ${GST_LIBRARIES} + m + pthread +) + +target_compile_options(NativeVideoPlayer PRIVATE + ${GST_CFLAGS_OTHER} + -Wall -Wextra -Wno-unused-parameter + -O2 + -fPIC +) + +# Determine output directory based on architecture +execute_process(COMMAND uname -m OUTPUT_VARIABLE ARCH OUTPUT_STRIP_TRAILING_WHITESPACE) +if(ARCH STREQUAL "x86_64" OR ARCH STREQUAL "amd64") + set(RESOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/linux-x86-64") +elseif(ARCH STREQUAL "aarch64" OR ARCH STREQUAL "arm64") + set(RESOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/linux-aarch64") +else() + set(RESOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../resources/linux-${ARCH}") +endif() + +# Copy library to resources after build +add_custom_command(TARGET NativeVideoPlayer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${RESOURCE_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy "$" "${RESOURCE_DIR}/" + COMMENT "Copying libNativeVideoPlayer.so to ${RESOURCE_DIR}" +) diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c new file mode 100644 index 00000000..caa1dcf1 --- /dev/null +++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c @@ -0,0 +1,712 @@ +// NativeVideoPlayer.c — Linux GStreamer-based native video player +// Uses GStreamer C API directly: playbin + appsink + level element. +// Bus messages are processed by a dedicated polling thread (no GLib main loop needed). + +#include "NativeVideoPlayer.h" + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Internal structures +// --------------------------------------------------------------------------- + +struct VideoPlayer { + GstElement* pipeline; // playbin + GstElement* video_sink; // appsink + GstElement* audio_bin; // custom audio bin with scaletempo + level + GstElement* level; // level element reference + + // Frame buffer (BGRA) + pthread_mutex_t frame_lock; + uint8_t* frame_buffer; + int32_t frame_width; + int32_t frame_height; + size_t frame_size; + + // Output scaling + int32_t output_width; + int32_t output_height; + + // Audio levels + float left_level; + float right_level; + + // Metadata + pthread_mutex_t meta_lock; + char* title; + int64_t bitrate; + char* mime_type; + int32_t audio_channels; + int32_t audio_sample_rate; + float frame_rate; + + // Playback state + float volume; + float playback_speed; + int did_play_to_end; // atomic flag + + // Bus polling thread + pthread_t bus_thread; + volatile int bus_thread_running; +}; + +// --------------------------------------------------------------------------- +// Forward declarations +// --------------------------------------------------------------------------- + +static void process_bus_message(VideoPlayer* p, GstMessage* msg); +static void* bus_thread_func(void* data); +static GstFlowReturn on_new_sample(GstAppSink* sink, gpointer data); +static void update_metadata_from_tags(VideoPlayer* p, GstTagList* tags); +static void update_stream_metadata(VideoPlayer* p); + +// --------------------------------------------------------------------------- +// GStreamer init (once) +// --------------------------------------------------------------------------- + +static pthread_once_t gst_init_once = PTHREAD_ONCE_INIT; + +static void gst_init_func(void) { + gst_init(NULL, NULL); +} + +// --------------------------------------------------------------------------- +// Bus polling thread +// --------------------------------------------------------------------------- + +static void* bus_thread_func(void* data) { + VideoPlayer* p = (VideoPlayer*)data; + GstBus* bus = gst_element_get_bus(p->pipeline); + + while (p->bus_thread_running) { + // Block up to 100ms waiting for a message + GstMessage* msg = gst_bus_timed_pop(bus, 100 * GST_MSECOND); + if (msg) { + process_bus_message(p, msg); + gst_message_unref(msg); + } + } + + gst_object_unref(bus); + return NULL; +} + +static void process_bus_message(VideoPlayer* p, GstMessage* msg) { + switch (GST_MESSAGE_TYPE(msg)) { + case GST_MESSAGE_EOS: + __sync_lock_test_and_set(&p->did_play_to_end, 1); + break; + + case GST_MESSAGE_ERROR: { + GError* err = NULL; + gchar* debug = NULL; + gst_message_parse_error(msg, &err, &debug); + if (err) { + g_printerr("GStreamer error: %s\n", err->message); + g_error_free(err); + } + if (debug) g_free(debug); + break; + } + + case GST_MESSAGE_TAG: { + GstTagList* tags = NULL; + gst_message_parse_tag(msg, &tags); + if (tags) { + update_metadata_from_tags(p, tags); + gst_tag_list_unref(tags); + } + break; + } + + case GST_MESSAGE_STATE_CHANGED: { + if (GST_MESSAGE_SRC(msg) == GST_OBJECT(p->pipeline)) { + GstState old_state, new_state; + gst_message_parse_state_changed(msg, &old_state, &new_state, NULL); + if (new_state == GST_STATE_PAUSED || new_state == GST_STATE_PLAYING) { + update_stream_metadata(p); + } + } + break; + } + + case GST_MESSAGE_ELEMENT: { + if (p->level && GST_MESSAGE_SRC(msg) == GST_OBJECT(p->level)) { + const GstStructure* st = gst_message_get_structure(msg); + if (st && gst_structure_has_name(st, "level")) { + const GValue* peak_val = gst_structure_get_value(st, "peak"); + if (peak_val && GST_VALUE_HOLDS_ARRAY(peak_val)) { + guint n = gst_value_array_get_size(peak_val); + if (n >= 1) { + const GValue* v0 = gst_value_array_get_value(peak_val, 0); + gdouble db_left = g_value_get_double(v0); + p->left_level = (float)pow(10.0, db_left / 20.0); + } + if (n >= 2) { + const GValue* v1 = gst_value_array_get_value(peak_val, 1); + gdouble db_right = g_value_get_double(v1); + p->right_level = (float)pow(10.0, db_right / 20.0); + } else { + p->right_level = p->left_level; + } + } + } + } + break; + } + + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +VideoPlayer* nvp_create(void) { + pthread_once(&gst_init_once, gst_init_func); + + VideoPlayer* p = calloc(1, sizeof(VideoPlayer)); + if (!p) return NULL; + + pthread_mutex_init(&p->frame_lock, NULL); + pthread_mutex_init(&p->meta_lock, NULL); + p->volume = 1.0f; + p->playback_speed = 1.0f; + + // Create playbin + p->pipeline = gst_element_factory_make("playbin", NULL); + if (!p->pipeline) { + pthread_mutex_destroy(&p->frame_lock); + pthread_mutex_destroy(&p->meta_lock); + free(p); + return NULL; + } + + // Create appsink for video frames + p->video_sink = gst_element_factory_make("appsink", NULL); + if (!p->video_sink) { + gst_object_unref(p->pipeline); + pthread_mutex_destroy(&p->frame_lock); + pthread_mutex_destroy(&p->meta_lock); + free(p); + return NULL; + } + + // Configure appsink: BGRA format for direct Skia consumption + GstCaps* caps = gst_caps_new_simple("video/x-raw", + "format", G_TYPE_STRING, "BGRA", + NULL); + gst_app_sink_set_caps(GST_APP_SINK(p->video_sink), caps); + gst_caps_unref(caps); + + // Configure appsink for media playback: + // - sync=true: deliver frames at correct presentation time (default) + // - drop=true: skip stale frames if app is slow to consume + // - max-buffers=2: small buffer to smooth out jitter without adding latency + // - emit-signals=true: callback from streaming thread + gst_app_sink_set_emit_signals(GST_APP_SINK(p->video_sink), TRUE); + gst_app_sink_set_drop(GST_APP_SINK(p->video_sink), TRUE); + gst_app_sink_set_max_buffers(GST_APP_SINK(p->video_sink), 2); + + // Connect new-sample callback (called from GStreamer's streaming thread) + g_signal_connect(p->video_sink, "new-sample", G_CALLBACK(on_new_sample), p); + + // Insert a queue before the appsink to decouple the decoder streaming + // thread from frame extraction. This prevents jitter from the memcpy/mutex + // in on_new_sample blocking the upstream decoder. + GstElement* video_queue = gst_element_factory_make("queue", NULL); + if (video_queue) { + g_object_set(video_queue, + "max-size-buffers", (guint)3, + "max-size-bytes", (guint)0, + "max-size-time", (guint64)0, + NULL); + + GstBin* video_bin = GST_BIN(gst_bin_new("videobin")); + gst_bin_add_many(video_bin, video_queue, p->video_sink, NULL); + gst_element_link(video_queue, p->video_sink); + + // Ghost pad for the bin input + GstPad* queue_sink = gst_element_get_static_pad(video_queue, "sink"); + GstPad* ghost_pad = gst_ghost_pad_new("sink", queue_sink); + gst_element_add_pad(GST_ELEMENT(video_bin), ghost_pad); + gst_object_unref(queue_sink); + + g_object_set(p->pipeline, "video-sink", video_bin, NULL); + } else { + // Fallback: direct appsink without queue + g_object_set(p->pipeline, "video-sink", p->video_sink, NULL); + } + + // Build audio bin: scaletempo -> level -> autoaudiosink + p->audio_bin = gst_bin_new("audiobin"); + GstElement* scaletempo = gst_element_factory_make("scaletempo", NULL); + p->level = gst_element_factory_make("level", NULL); + GstElement* audio_sink = gst_element_factory_make("autoaudiosink", NULL); + + if (!scaletempo || !p->level || !audio_sink) { + if (scaletempo) gst_object_unref(scaletempo); + if (p->level) { gst_object_unref(p->level); p->level = NULL; } + if (audio_sink) gst_object_unref(audio_sink); + gst_object_unref(p->audio_bin); + p->audio_bin = NULL; + } else { + g_object_set(p->level, "post-messages", TRUE, NULL); + g_object_set(p->level, "interval", (guint64)(100 * GST_MSECOND), NULL); + + gst_bin_add_many(GST_BIN(p->audio_bin), scaletempo, p->level, audio_sink, NULL); + gst_element_link_many(scaletempo, p->level, audio_sink, NULL); + + GstPad* sink_pad = gst_element_get_static_pad(scaletempo, "sink"); + GstPad* ghost = gst_ghost_pad_new("sink", sink_pad); + gst_element_add_pad(p->audio_bin, ghost); + gst_object_unref(sink_pad); + + g_object_set(p->pipeline, "audio-sink", p->audio_bin, NULL); + } + + // Start bus polling thread + p->bus_thread_running = 1; + pthread_create(&p->bus_thread, NULL, bus_thread_func, p); + + return p; +} + +void nvp_destroy(VideoPlayer* p) { + if (!p) return; + + // Stop pipeline first — this flushes all streaming threads and ensures + // on_new_sample will no longer be called before we free resources. + gst_element_set_state(p->pipeline, GST_STATE_NULL); + + // Now stop bus thread (no more messages will arrive after NULL state) + p->bus_thread_running = 0; + pthread_join(p->bus_thread, NULL); + + gst_object_unref(p->pipeline); + + pthread_mutex_lock(&p->frame_lock); + free(p->frame_buffer); + p->frame_buffer = NULL; + pthread_mutex_unlock(&p->frame_lock); + pthread_mutex_destroy(&p->frame_lock); + + pthread_mutex_lock(&p->meta_lock); + free(p->title); + free(p->mime_type); + pthread_mutex_unlock(&p->meta_lock); + pthread_mutex_destroy(&p->meta_lock); + + free(p); +} + +// --------------------------------------------------------------------------- +// Playback control +// --------------------------------------------------------------------------- + +int nvp_open_uri(VideoPlayer* p, const char* uri) { + if (!p || !uri) return 0; + + // Reset state + gst_element_set_state(p->pipeline, GST_STATE_NULL); + p->did_play_to_end = 0; + + // Clear old frame + pthread_mutex_lock(&p->frame_lock); + free(p->frame_buffer); + p->frame_buffer = NULL; + p->frame_width = 0; + p->frame_height = 0; + p->frame_size = 0; + pthread_mutex_unlock(&p->frame_lock); + + // Clear metadata + pthread_mutex_lock(&p->meta_lock); + free(p->title); p->title = NULL; + free(p->mime_type); p->mime_type = NULL; + p->bitrate = 0; + p->audio_channels = 0; + p->audio_sample_rate = 0; + p->frame_rate = 0.0f; + pthread_mutex_unlock(&p->meta_lock); + + p->left_level = 0.0f; + p->right_level = 0.0f; + + // Convert raw file paths to file:// URIs if needed. + // GStreamer playbin requires a valid URI scheme. + gchar* resolved_uri = NULL; + if (g_str_has_prefix(uri, "http://") || g_str_has_prefix(uri, "https://") || + g_str_has_prefix(uri, "rtsp://") || g_str_has_prefix(uri, "file://")) { + resolved_uri = g_strdup(uri); + } else { + // Treat as a local file path — convert to file:// URI + GError* err = NULL; + resolved_uri = gst_filename_to_uri(uri, &err); + if (!resolved_uri) { + if (err) { + g_printerr("Failed to convert path to URI: %s\n", err->message); + g_error_free(err); + } + return 0; + } + } + + g_object_set(p->pipeline, "uri", resolved_uri, NULL); + g_free(resolved_uri); + g_object_set(p->pipeline, "volume", (gdouble)p->volume, NULL); + + // Pause to preroll (caller will call play() when ready) + GstStateChangeReturn ret = gst_element_set_state(p->pipeline, GST_STATE_PAUSED); + if (ret == GST_STATE_CHANGE_FAILURE) { + return 0; + } + + return 1; +} + +void nvp_play(VideoPlayer* p) { + if (!p) return; + g_object_set(p->pipeline, "volume", (gdouble)p->volume, NULL); + gst_element_set_state(p->pipeline, GST_STATE_PLAYING); +} + +void nvp_pause(VideoPlayer* p) { + if (!p) return; + gst_element_set_state(p->pipeline, GST_STATE_PAUSED); +} + +void nvp_set_volume(VideoPlayer* p, float volume) { + if (!p) return; + p->volume = volume; + g_object_set(p->pipeline, "volume", (gdouble)volume, NULL); +} + +float nvp_get_volume(VideoPlayer* p) { + return p ? p->volume : 0.0f; +} + +void nvp_seek_to(VideoPlayer* p, double time_seconds) { + if (!p) return; + gint64 pos = (gint64)(time_seconds * GST_SECOND); + gst_element_seek(p->pipeline, + (gdouble)p->playback_speed, + GST_FORMAT_TIME, + GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE, + GST_SEEK_TYPE_SET, pos, + GST_SEEK_TYPE_NONE, -1); +} + +void nvp_set_playback_speed(VideoPlayer* p, float speed) { + if (!p) return; + p->playback_speed = speed; + + gint64 pos = 0; + if (gst_element_query_position(p->pipeline, GST_FORMAT_TIME, &pos)) { + gst_element_seek(p->pipeline, + (gdouble)speed, + GST_FORMAT_TIME, + GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE, + GST_SEEK_TYPE_SET, pos, + GST_SEEK_TYPE_NONE, -1); + } +} + +float nvp_get_playback_speed(VideoPlayer* p) { + return p ? p->playback_speed : 1.0f; +} + +// --------------------------------------------------------------------------- +// Frame access +// --------------------------------------------------------------------------- + +void* nvp_get_latest_frame_address(VideoPlayer* p) { + if (!p) return NULL; + return p->frame_buffer; +} + +int32_t nvp_get_frame_width(VideoPlayer* p) { + return p ? p->frame_width : 0; +} + +int32_t nvp_get_frame_height(VideoPlayer* p) { + return p ? p->frame_height : 0; +} + +int32_t nvp_set_output_size(VideoPlayer* p, int32_t width, int32_t height) { + if (!p || width <= 0 || height <= 0) return 0; + p->output_width = width; + p->output_height = height; + + GstCaps* caps = gst_caps_new_simple("video/x-raw", + "format", G_TYPE_STRING, "BGRA", + "width", G_TYPE_INT, (gint)width, + "height", G_TYPE_INT, (gint)height, + NULL); + gst_app_sink_set_caps(GST_APP_SINK(p->video_sink), caps); + gst_caps_unref(caps); + return 1; +} + +// --------------------------------------------------------------------------- +// Timing +// --------------------------------------------------------------------------- + +double nvp_get_duration(VideoPlayer* p) { + if (!p) return 0.0; + gint64 dur = 0; + if (gst_element_query_duration(p->pipeline, GST_FORMAT_TIME, &dur) && dur > 0) { + return (double)dur / (double)GST_SECOND; + } + return 0.0; +} + +double nvp_get_current_time(VideoPlayer* p) { + if (!p) return 0.0; + gint64 pos = 0; + if (gst_element_query_position(p->pipeline, GST_FORMAT_TIME, &pos) && pos >= 0) { + return (double)pos / (double)GST_SECOND; + } + return 0.0; +} + +// --------------------------------------------------------------------------- +// Audio levels +// --------------------------------------------------------------------------- + +float nvp_get_left_audio_level(VideoPlayer* p) { + return p ? p->left_level : 0.0f; +} + +float nvp_get_right_audio_level(VideoPlayer* p) { + return p ? p->right_level : 0.0f; +} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +char* nvp_get_title(VideoPlayer* p) { + if (!p) return NULL; + pthread_mutex_lock(&p->meta_lock); + char* result = p->title ? strdup(p->title) : NULL; + pthread_mutex_unlock(&p->meta_lock); + return result; +} + +int64_t nvp_get_bitrate(VideoPlayer* p) { + return p ? p->bitrate : 0; +} + +char* nvp_get_mime_type(VideoPlayer* p) { + if (!p) return NULL; + pthread_mutex_lock(&p->meta_lock); + char* result = p->mime_type ? strdup(p->mime_type) : NULL; + pthread_mutex_unlock(&p->meta_lock); + return result; +} + +int32_t nvp_get_audio_channels(VideoPlayer* p) { + return p ? p->audio_channels : 0; +} + +int32_t nvp_get_audio_sample_rate(VideoPlayer* p) { + return p ? p->audio_sample_rate : 0; +} + +float nvp_get_frame_rate(VideoPlayer* p) { + return p ? p->frame_rate : 0.0f; +} + +// --------------------------------------------------------------------------- +// End-of-stream +// --------------------------------------------------------------------------- + +int32_t nvp_consume_did_play_to_end(VideoPlayer* p) { + if (!p) return 0; + int val = __sync_lock_test_and_set(&p->did_play_to_end, 0); + return val; +} + +// --------------------------------------------------------------------------- +// Internal: new-sample callback (called from GStreamer streaming thread) +// --------------------------------------------------------------------------- + +static GstFlowReturn on_new_sample(GstAppSink* sink, gpointer data) { + VideoPlayer* p = (VideoPlayer*)data; + + GstSample* sample = gst_app_sink_pull_sample(sink); + if (!sample) return GST_FLOW_OK; + + GstCaps* caps = gst_sample_get_caps(sample); + if (!caps) { + gst_sample_unref(sample); + return GST_FLOW_OK; + } + + GstStructure* s = gst_caps_get_structure(caps, 0); + gint width = 0, height = 0; + gst_structure_get_int(s, "width", &width); + gst_structure_get_int(s, "height", &height); + + if (width <= 0 || height <= 0) { + gst_sample_unref(sample); + return GST_FLOW_OK; + } + + // Extract frame rate from caps + gint fps_n = 0, fps_d = 1; + if (gst_structure_get_fraction(s, "framerate", &fps_n, &fps_d) && fps_d > 0) { + p->frame_rate = (float)fps_n / (float)fps_d; + } + + GstBuffer* buffer = gst_sample_get_buffer(sample); + if (!buffer) { + gst_sample_unref(sample); + return GST_FLOW_OK; + } + + GstMapInfo map; + if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) { + gst_sample_unref(sample); + return GST_FLOW_OK; + } + + size_t expected = (size_t)width * (size_t)height * 4; + if (map.size < expected) { + gst_buffer_unmap(buffer, &map); + gst_sample_unref(sample); + return GST_FLOW_OK; + } + + pthread_mutex_lock(&p->frame_lock); + + if (p->frame_width != width || p->frame_height != height || !p->frame_buffer) { + free(p->frame_buffer); + p->frame_buffer = (uint8_t*)malloc(expected); + if (!p->frame_buffer) { + p->frame_width = 0; + p->frame_height = 0; + p->frame_size = 0; + pthread_mutex_unlock(&p->frame_lock); + gst_buffer_unmap(buffer, &map); + gst_sample_unref(sample); + return GST_FLOW_OK; + } + p->frame_width = width; + p->frame_height = height; + p->frame_size = expected; + } + + memcpy(p->frame_buffer, map.data, expected); + + pthread_mutex_unlock(&p->frame_lock); + + gst_buffer_unmap(buffer, &map); + gst_sample_unref(sample); + return GST_FLOW_OK; +} + +// --------------------------------------------------------------------------- +// Internal: metadata extraction from tags +// --------------------------------------------------------------------------- + +static void update_metadata_from_tags(VideoPlayer* p, GstTagList* tags) { + gchar* str = NULL; + + pthread_mutex_lock(&p->meta_lock); + + if (gst_tag_list_get_string(tags, GST_TAG_TITLE, &str)) { + free(p->title); + p->title = strdup(str); + g_free(str); + } + + guint bitrate = 0; + if (gst_tag_list_get_uint(tags, GST_TAG_BITRATE, &bitrate) || + gst_tag_list_get_uint(tags, GST_TAG_NOMINAL_BITRATE, &bitrate)) { + p->bitrate = (int64_t)bitrate; + } + + str = NULL; + if (gst_tag_list_get_string(tags, GST_TAG_CONTAINER_FORMAT, &str)) { + free(p->mime_type); + p->mime_type = strdup(str); + g_free(str); + } else if (gst_tag_list_get_string(tags, GST_TAG_AUDIO_CODEC, &str)) { + if (!p->mime_type) { + p->mime_type = strdup(str); + } + g_free(str); + } else if (gst_tag_list_get_string(tags, GST_TAG_VIDEO_CODEC, &str)) { + if (!p->mime_type) { + p->mime_type = strdup(str); + } + g_free(str); + } + + pthread_mutex_unlock(&p->meta_lock); +} + +// --------------------------------------------------------------------------- +// Internal: stream metadata from pads (channels, sample rate, resolution) +// --------------------------------------------------------------------------- + +static void update_stream_metadata(VideoPlayer* p) { + // Video info from appsink pad + GstPad* vpad = gst_element_get_static_pad(p->video_sink, "sink"); + if (vpad) { + GstCaps* vcaps = gst_pad_get_current_caps(vpad); + if (vcaps && gst_caps_get_size(vcaps) > 0) { + GstStructure* vs = gst_caps_get_structure(vcaps, 0); + gint w = 0, h = 0; + gst_structure_get_int(vs, "width", &w); + gst_structure_get_int(vs, "height", &h); + + // Only update dimensions if not already set by frame callback + if (w > 0 && h > 0 && (p->frame_width == 0 || p->frame_height == 0)) { + pthread_mutex_lock(&p->frame_lock); + if (p->frame_width == 0 || p->frame_height == 0) { + p->frame_width = w; + p->frame_height = h; + } + pthread_mutex_unlock(&p->frame_lock); + } + + gint fps_n = 0, fps_d = 1; + if (gst_structure_get_fraction(vs, "framerate", &fps_n, &fps_d) && fps_d > 0) { + p->frame_rate = (float)fps_n / (float)fps_d; + } + } + if (vcaps) gst_caps_unref(vcaps); + gst_object_unref(vpad); + } + + // Audio info from the level element's sink pad + if (p->level) { + GstPad* apad = gst_element_get_static_pad(p->level, "sink"); + if (apad) { + GstCaps* acaps = gst_pad_get_current_caps(apad); + if (acaps && gst_caps_get_size(acaps) > 0) { + GstStructure* as_ = gst_caps_get_structure(acaps, 0); + gint channels = 0, rate = 0; + if (gst_structure_get_int(as_, "channels", &channels)) { + p->audio_channels = channels; + } + if (gst_structure_get_int(as_, "rate", &rate)) { + p->audio_sample_rate = rate; + } + } + if (acaps) gst_caps_unref(acaps); + gst_object_unref(apad); + } + } +} diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h new file mode 100644 index 00000000..5f3d485c --- /dev/null +++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h @@ -0,0 +1,59 @@ +// NativeVideoPlayer.h — Linux GStreamer-based native video player +// Pure C API for JNI consumption. + +#ifndef NATIVE_VIDEO_PLAYER_H +#define NATIVE_VIDEO_PLAYER_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Opaque player handle +typedef struct VideoPlayer VideoPlayer; + +// Lifecycle +VideoPlayer* nvp_create(void); +void nvp_destroy(VideoPlayer* p); + +// Playback control +int nvp_open_uri(VideoPlayer* p, const char* uri); +void nvp_play(VideoPlayer* p); +void nvp_pause(VideoPlayer* p); +void nvp_set_volume(VideoPlayer* p, float volume); +float nvp_get_volume(VideoPlayer* p); +void nvp_seek_to(VideoPlayer* p, double time_seconds); +void nvp_set_playback_speed(VideoPlayer* p, float speed); +float nvp_get_playback_speed(VideoPlayer* p); + +// Frame access +void* nvp_get_latest_frame_address(VideoPlayer* p); +int32_t nvp_get_frame_width(VideoPlayer* p); +int32_t nvp_get_frame_height(VideoPlayer* p); +int32_t nvp_set_output_size(VideoPlayer* p, int32_t width, int32_t height); + +// Timing +double nvp_get_duration(VideoPlayer* p); +double nvp_get_current_time(VideoPlayer* p); + +// Audio levels (0.0 - 1.0 linear) +float nvp_get_left_audio_level(VideoPlayer* p); +float nvp_get_right_audio_level(VideoPlayer* p); + +// Metadata (caller must free returned strings with free()) +char* nvp_get_title(VideoPlayer* p); +int64_t nvp_get_bitrate(VideoPlayer* p); +char* nvp_get_mime_type(VideoPlayer* p); +int32_t nvp_get_audio_channels(VideoPlayer* p); +int32_t nvp_get_audio_sample_rate(VideoPlayer* p); +float nvp_get_frame_rate(VideoPlayer* p); + +// End-of-stream notification (consumes the flag) +int32_t nvp_consume_did_play_to_end(VideoPlayer* p); + +#ifdef __cplusplus +} +#endif + +#endif // NATIVE_VIDEO_PLAYER_H diff --git a/mediaplayer/src/jvmMain/native/linux/build.sh b/mediaplayer/src/jvmMain/native/linux/build.sh new file mode 100755 index 00000000..ae5f2ed0 --- /dev/null +++ b/mediaplayer/src/jvmMain/native/linux/build.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" + +echo "=== Building Linux NativeVideoPlayer ===" + +# Clean and create build directory +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +cd "$BUILD_DIR" +cmake "$SCRIPT_DIR" -DCMAKE_BUILD_TYPE=Release +cmake --build . --parallel + +echo "=== Build completed ===" + +# Show output location +ARCH=$(uname -m) +case "$ARCH" in + x86_64|amd64) + echo "Output: $SCRIPT_DIR/../../resources/linux-x86-64/libNativeVideoPlayer.so" + ;; + aarch64|arm64) + echo "Output: $SCRIPT_DIR/../../resources/linux-aarch64/libNativeVideoPlayer.so" + ;; + *) + echo "Output: $SCRIPT_DIR/../../resources/linux-$ARCH/libNativeVideoPlayer.so" + ;; +esac diff --git a/mediaplayer/src/jvmMain/native/linux/jni_bridge.c b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c new file mode 100644 index 00000000..d24c59cb --- /dev/null +++ b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c @@ -0,0 +1,190 @@ +// jni_bridge.c — JNI bridge for Linux NativeVideoPlayer +// Maps Kotlin external methods to the native C API and registers via JNI_OnLoad. + +#include +#include +#include +#include "NativeVideoPlayer.h" + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +static inline VideoPlayer* toCtx(jlong h) { + return (VideoPlayer*)(uintptr_t)(uint64_t)h; +} + +// --------------------------------------------------------------------------- +// JNI implementations +// --------------------------------------------------------------------------- + +static jlong JNICALL jni_CreatePlayer(JNIEnv* env, jclass cls) { + VideoPlayer* p = nvp_create(); + return p ? (jlong)(uintptr_t)p : 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; + nvp_open_uri(toCtx(handle), cUri); + (*env)->ReleaseStringUTFChars(env, uri, cUri); +} + +static void JNICALL jni_Play(JNIEnv* env, jclass cls, jlong handle) { + if (handle) nvp_play(toCtx(handle)); +} + +static void JNICALL jni_Pause(JNIEnv* env, jclass cls, jlong handle) { + if (handle) nvp_pause(toCtx(handle)); +} + +static void JNICALL jni_SetVolume(JNIEnv* env, jclass cls, jlong handle, jfloat volume) { + if (handle) nvp_set_volume(toCtx(handle), (float)volume); +} + +static jfloat JNICALL jni_GetVolume(JNIEnv* env, jclass cls, jlong handle) { + return handle ? nvp_get_volume(toCtx(handle)) : 0.0f; +} + +static jlong JNICALL jni_GetLatestFrameAddress(JNIEnv* env, jclass cls, jlong handle) { + if (!handle) return 0L; + void* ptr = nvp_get_latest_frame_address(toCtx(handle)); + return ptr ? (jlong)(uintptr_t)ptr : 0L; +} + +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)nvp_get_frame_width(toCtx(handle)) : 0; +} + +static jint JNICALL jni_GetFrameHeight(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)nvp_get_frame_height(toCtx(handle)) : 0; +} + +static jint JNICALL jni_SetOutputSize(JNIEnv* env, jclass cls, jlong handle, jint width, jint height) { + return handle ? (jint)nvp_set_output_size(toCtx(handle), (int32_t)width, (int32_t)height) : 0; +} + +static jdouble JNICALL jni_GetVideoDuration(JNIEnv* env, jclass cls, jlong handle) { + return handle ? nvp_get_duration(toCtx(handle)) : 0.0; +} + +static jdouble JNICALL jni_GetCurrentTime(JNIEnv* env, jclass cls, jlong handle) { + return handle ? nvp_get_current_time(toCtx(handle)) : 0.0; +} + +static void JNICALL jni_SeekTo(JNIEnv* env, jclass cls, jlong handle, jdouble time) { + if (handle) nvp_seek_to(toCtx(handle), (double)time); +} + +static void JNICALL jni_DisposePlayer(JNIEnv* env, jclass cls, jlong handle) { + if (handle) nvp_destroy(toCtx(handle)); +} + +static jfloat JNICALL jni_GetLeftAudioLevel(JNIEnv* env, jclass cls, jlong handle) { + return handle ? nvp_get_left_audio_level(toCtx(handle)) : 0.0f; +} + +static jfloat JNICALL jni_GetRightAudioLevel(JNIEnv* env, jclass cls, jlong handle) { + return handle ? nvp_get_right_audio_level(toCtx(handle)) : 0.0f; +} + +static void JNICALL jni_SetPlaybackSpeed(JNIEnv* env, jclass cls, jlong handle, jfloat speed) { + if (handle) nvp_set_playback_speed(toCtx(handle), (float)speed); +} + +static jfloat JNICALL jni_GetPlaybackSpeed(JNIEnv* env, jclass cls, jlong handle) { + return handle ? nvp_get_playback_speed(toCtx(handle)) : 1.0f; +} + +static jstring JNICALL jni_GetVideoTitle(JNIEnv* env, jclass cls, jlong handle) { + if (!handle) return NULL; + char* s = nvp_get_title(toCtx(handle)); + if (!s) return NULL; + jstring result = (*env)->NewStringUTF(env, s); + free(s); + return result; +} + +static jlong JNICALL jni_GetVideoBitrate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jlong)nvp_get_bitrate(toCtx(handle)) : 0L; +} + +static jstring JNICALL jni_GetVideoMimeType(JNIEnv* env, jclass cls, jlong handle) { + if (!handle) return NULL; + char* s = nvp_get_mime_type(toCtx(handle)); + if (!s) return NULL; + jstring result = (*env)->NewStringUTF(env, s); + free(s); + return result; +} + +static jint JNICALL jni_GetAudioChannels(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)nvp_get_audio_channels(toCtx(handle)) : 0; +} + +static jint JNICALL jni_GetAudioSampleRate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jint)nvp_get_audio_sample_rate(toCtx(handle)) : 0; +} + +static jfloat JNICALL jni_GetFrameRate(JNIEnv* env, jclass cls, jlong handle) { + return handle ? nvp_get_frame_rate(toCtx(handle)) : 0.0f; +} + +static jboolean JNICALL jni_ConsumeDidPlayToEnd(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jboolean)(nvp_consume_did_play_to_end(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 }, + { "nGetLatestFrameAddress", "(J)J", (void*)jni_GetLatestFrameAddress }, + { "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 }, + { "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 }, + { "nGetFrameRate", "(J)F", (void*)jni_GetFrameRate }, + { "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/linux/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/mediaplayer/src/jvmMain/resources/linux-x86-64/libNativeVideoPlayer.so b/mediaplayer/src/jvmMain/resources/linux-x86-64/libNativeVideoPlayer.so new file mode 100755 index 0000000000000000000000000000000000000000..98d78dce7d1acaba604a11198c3c56e12663c1aa GIT binary patch literal 42160 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 T#yeL0pL|hDoUcXDSmVC{XmM3H literal 0 HcmV?d00001 diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt index 1ef74f68..3bb4c23d 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt @@ -7,10 +7,7 @@ import kotlin.test.assertTrue import kotlin.test.assertFalse import kotlinx.coroutines.runBlocking import kotlinx.coroutines.delay -import com.sun.jna.Platform -import org.freedesktop.gstreamer.ElementFactory -import org.freedesktop.gstreamer.Gst -import org.freedesktop.gstreamer.Version +import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform /** * Tests for the JVM implementation of VideoPlayerState @@ -18,48 +15,31 @@ import org.freedesktop.gstreamer.Version class VideoPlayerStateTest { /** - * Checks if GStreamer is available and the playbin element can be created. - * This is used to skip tests when GStreamer is not properly installed or configured. - * GStreamer is only checked on Linux platforms. + * Checks if the native video player library is available. + * On Linux, this requires the native GStreamer JNI library. + * On macOS, this requires the AVFoundation JNI library. + * On Windows, this requires the Media Foundation JNI library. */ - private fun isGStreamerAvailable(): Boolean { - // Only check for GStreamer on Linux platforms - if (!Platform.isLinux()) { - println("Skipping GStreamer check: Not on Linux platform") - return false - } - - try { - // Try to initialize GStreamer if it's not already initialized - if (!Gst.isInitialized()) { - Gst.init(Version.BASELINE, "ComposeGStreamerPlayerTest") - } - - // Try to create a playbin element - val element = ElementFactory.make("playbin", "testPlaybin") - val isAvailable = element != null - element?.dispose() - return isAvailable + private fun isNativePlayerAvailable(): Boolean { + return try { + val state = createVideoPlayerState() + state.dispose() + true } catch (e: Exception) { - println("GStreamer is not available: ${e.message}") - return false + println("Native player not available: ${e.message}") + false } } - /** - * Test the creation of VideoPlayerState - */ @Test fun testCreateVideoPlayerState() { - // Skip test if GStreamer is not available - if (!isGStreamerAvailable()) { - println("Skipping test: GStreamer is not available") + if (!isNativePlayerAvailable()) { + println("Skipping test: Native player not available") return } val playerState = createVideoPlayerState() - // Verify the player state is initialized correctly assertNotNull(playerState) assertFalse(playerState.hasMedia) assertFalse(playerState.isPlaying) @@ -72,125 +52,93 @@ class VideoPlayerStateTest { assertEquals(0f, playerState.rightLevel) assertFalse(playerState.isFullscreen) - // Clean up playerState.dispose() } - /** - * Test volume control - */ @Test fun testVolumeControl() { - // Skip test if GStreamer is not available - if (!isGStreamerAvailable()) { - println("Skipping test: GStreamer is not available") + if (!isNativePlayerAvailable()) { + println("Skipping test: Native player not available") return } val playerState = createVideoPlayerState() - // Test initial volume assertEquals(1f, playerState.volume) - // Test setting volume playerState.volume = 0.5f assertEquals(0.5f, playerState.volume) - // Test volume bounds playerState.volume = -0.1f assertEquals(0f, playerState.volume, "Volume should be clamped to 0") playerState.volume = 1.5f assertEquals(1f, playerState.volume, "Volume should be clamped to 1") - // Clean up playerState.dispose() } - /** - * Test loop setting - */ @Test fun testLoopSetting() { - // Skip test if GStreamer is not available - if (!isGStreamerAvailable()) { - println("Skipping test: GStreamer is not available") + if (!isNativePlayerAvailable()) { + println("Skipping test: Native player not available") return } val playerState = createVideoPlayerState() - // Test initial loop setting assertFalse(playerState.loop) - // Test setting loop playerState.loop = true assertTrue(playerState.loop) playerState.loop = false assertFalse(playerState.loop) - // Clean up playerState.dispose() } - /** - * Test fullscreen toggle - */ @Test fun testFullscreenToggle() { - // Skip test if GStreamer is not available - if (!isGStreamerAvailable()) { - println("Skipping test: GStreamer is not available") + if (!isNativePlayerAvailable()) { + println("Skipping test: Native player not available") return } val playerState = createVideoPlayerState() - // Test initial fullscreen state assertFalse(playerState.isFullscreen) - // Test toggling fullscreen playerState.toggleFullscreen() assertTrue(playerState.isFullscreen) playerState.toggleFullscreen() assertFalse(playerState.isFullscreen) - // Clean up playerState.dispose() } - /** - * Test error handling - */ @Test fun testErrorHandling() { - // Skip test if GStreamer is not available - if (!isGStreamerAvailable()) { - println("Skipping test: GStreamer is not available") + if (!isNativePlayerAvailable()) { + println("Skipping test: Native player not available") return } val playerState = createVideoPlayerState() - // Initially there should be no error assertEquals(null, playerState.error) - // Test opening a non-existent file (should cause an error) runBlocking { playerState.openUri("non_existent_file.mp4") - delay(500) // Give some time for the error to be set + delay(500) } - // There should be an error now assertNotNull(playerState.error) - // Test clearing the error playerState.clearError() assertEquals(null, playerState.error) - // Clean up playerState.dispose() } } diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt index 579c9903..052649cb 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt @@ -1,10 +1,8 @@ package io.github.kdroidfilter.composemediaplayer.linux -import com.sun.jna.Platform +import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import org.freedesktop.gstreamer.Gst -import org.freedesktop.gstreamer.Version import org.junit.Assume import org.junit.Before import kotlin.test.Test @@ -16,56 +14,34 @@ import kotlin.test.assertTrue /** * Tests for the Linux implementation of VideoPlayerState - * + * * Note: These tests will only run on Linux platforms. On other platforms, * the tests will be skipped. - * - * Additionally, these tests require GStreamer to be properly installed and - * the "playbin" element to be available. If GStreamer is not available, - * the tests will be skipped. + * + * Additionally, these tests require the native GStreamer library to be available. + * If the native library cannot be loaded, the tests will be skipped. */ class LinuxVideoPlayerStateTest { - /** - * Setup method to check if GStreamer is available before running tests. - * If GStreamer is not available or the "playbin" element is not found, - * the tests will be skipped. - */ @Before fun setup() { // Skip test if not running on Linux - Assume.assumeTrue("Skipping Linux-specific test on non-Linux platform", Platform.isLinux()) + Assume.assumeTrue("Skipping Linux-specific test on non-Linux platform", CurrentPlatform.os == CurrentPlatform.OS.LINUX) - // Try to initialize GStreamer + // Try to load the native library try { - // Initialize GStreamer - if (!Gst.isInitialized()) { - Gst.init(Version.BASELINE, "TestGStreamerPlayer") - } - - // Try to create a "playbin" element to check if it's available - try { - val playbin = org.freedesktop.gstreamer.ElementFactory.make("playbin", "testplaybin") - Assume.assumeNotNull("GStreamer 'playbin' element not available", playbin) - playbin.dispose() // Clean up the test element - } catch (e: Exception) { - // If playbin creation fails, skip the test - Assume.assumeNoException("GStreamer 'playbin' element not available", e) + SharedVideoPlayer.nCreatePlayer().let { ptr -> + if (ptr != 0L) SharedVideoPlayer.nDisposePlayer(ptr) } } catch (e: Exception) { - // If GStreamer initialization fails, skip the test - Assume.assumeNoException("GStreamer initialization failed", e) + Assume.assumeNoException("Native video player library not available", e) } } - /** - * Test the creation of LinuxVideoPlayerState - */ @Test fun testCreateLinuxVideoPlayerState() { val playerState = LinuxVideoPlayerState() - // Verify the player state is initialized correctly assertNotNull(playerState) assertFalse(playerState.hasMedia) assertFalse(playerState.isPlaying) @@ -79,101 +55,73 @@ class LinuxVideoPlayerStateTest { assertFalse(playerState.isFullscreen) assertNull(playerState.error) - // Clean up playerState.dispose() } - /** - * Test volume control - */ @Test fun testVolumeControl() { val playerState = LinuxVideoPlayerState() - // Test initial volume assertEquals(1f, playerState.volume) - // Test setting volume playerState.volume = 0.5f assertEquals(0.5f, playerState.volume) - // Test volume bounds playerState.volume = -0.1f assertEquals(0f, playerState.volume, "Volume should be clamped to 0") playerState.volume = 1.5f assertEquals(1f, playerState.volume, "Volume should be clamped to 1") - // Clean up playerState.dispose() } - /** - * Test loop setting - */ @Test fun testLoopSetting() { val playerState = LinuxVideoPlayerState() - // Test initial loop setting assertFalse(playerState.loop) - // Test setting loop playerState.loop = true assertTrue(playerState.loop) playerState.loop = false assertFalse(playerState.loop) - // Clean up playerState.dispose() } - /** - * Test fullscreen toggle - */ @Test fun testFullscreenToggle() { val playerState = LinuxVideoPlayerState() - // Test initial fullscreen state assertFalse(playerState.isFullscreen) - // Test toggling fullscreen playerState.toggleFullscreen() assertTrue(playerState.isFullscreen) playerState.toggleFullscreen() assertFalse(playerState.isFullscreen) - // Clean up playerState.dispose() } - /** - * Test error handling - */ @Test fun testErrorHandling() { val playerState = LinuxVideoPlayerState() - // Initially there should be no error assertNull(playerState.error) - // Test opening a non-existent file (should cause an error) runBlocking { playerState.openUri("non_existent_file.mp4") - delay(500) // Give some time for the error to be set + delay(500) } - // There should be an error now assertNotNull(playerState.error) - // Test clearing the error playerState.clearError() assertNull(playerState.error) - // Clean up playerState.dispose() } } diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt index e27407c9..5511c57a 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import kotlinx.coroutines.delay -import com.sun.jna.Platform +import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform /** * Tests for the Mac implementation of VideoPlayerState @@ -24,7 +24,7 @@ class MacVideoPlayerStateTest { @Test fun testCreateMacVideoPlayerState() { // Skip test if not running on Mac - if (!Platform.isMac()) { + if (CurrentPlatform.os != CurrentPlatform.OS.MAC) { println("Skipping Mac-specific test on non-Mac platform") return } @@ -55,7 +55,7 @@ class MacVideoPlayerStateTest { @Test fun testVolumeControl() { // Skip test if not running on Mac - if (!Platform.isMac()) { + if (CurrentPlatform.os != CurrentPlatform.OS.MAC) { println("Skipping Mac-specific test on non-Mac platform") return } @@ -86,7 +86,7 @@ class MacVideoPlayerStateTest { @Test fun testLoopSetting() { // Skip test if not running on Mac - if (!Platform.isMac()) { + if (CurrentPlatform.os != CurrentPlatform.OS.MAC) { println("Skipping Mac-specific test on non-Mac platform") return } @@ -113,7 +113,7 @@ class MacVideoPlayerStateTest { @Test fun testFullscreenToggle() { // Skip test if not running on Mac - if (!Platform.isMac()) { + if (CurrentPlatform.os != CurrentPlatform.OS.MAC) { println("Skipping Mac-specific test on non-Mac platform") return } @@ -140,7 +140,7 @@ class MacVideoPlayerStateTest { @Test fun testErrorHandling() { // Skip test if not running on Mac - if (!Platform.isMac()) { + if (CurrentPlatform.os != CurrentPlatform.OS.MAC) { println("Skipping Mac-specific test on non-Mac platform") return } @@ -169,7 +169,7 @@ class MacVideoPlayerStateTest { private fun testOpenLocalFile(file: String) { // Skip test if not running on Mac - if (!Platform.isMac()) { + if (CurrentPlatform.os != CurrentPlatform.OS.MAC) { println("Skipping Mac-specific test on non-Mac platform") return } @@ -212,7 +212,7 @@ class MacVideoPlayerStateTest { private fun testMalformedUri(uri: String) { // Skip test if not running on Mac - if (!Platform.isMac()) { + if (CurrentPlatform.os != CurrentPlatform.OS.MAC) { println("Skipping Mac-specific test on non-Mac platform") return } diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt index 521deb1f..b8789492 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt @@ -1,6 +1,6 @@ package io.github.kdroidfilter.composemediaplayer.windows -import com.sun.jna.Platform +import io.github.kdroidfilter.composemediaplayer.util.CurrentPlatform import io.github.kdroidfilter.composemediaplayer.VideoPlayerError import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -25,7 +25,7 @@ class WindowsVideoPlayerStateTest { @Test fun testCreateWindowsVideoPlayerState() { // Skip test if not running on Windows - if (!Platform.isWindows()) { + if (CurrentPlatform.os != CurrentPlatform.OS.WINDOWS) { println("Skipping Windows-specific test on non-Windows platform") return } @@ -56,7 +56,7 @@ class WindowsVideoPlayerStateTest { @Test fun testVolumeControl() { // Skip test if not running on Windows - if (!Platform.isWindows()) { + if (CurrentPlatform.os != CurrentPlatform.OS.WINDOWS) { println("Skipping Windows-specific test on non-Windows platform") return } @@ -87,7 +87,7 @@ class WindowsVideoPlayerStateTest { @Test fun testLoopSetting() { // Skip test if not running on Windows - if (!Platform.isWindows()) { + if (CurrentPlatform.os != CurrentPlatform.OS.WINDOWS) { println("Skipping Windows-specific test on non-Windows platform") return } @@ -114,7 +114,7 @@ class WindowsVideoPlayerStateTest { @Test fun testFullscreenToggle() { // Skip test if not running on Windows - if (!Platform.isWindows()) { + if (CurrentPlatform.os != CurrentPlatform.OS.WINDOWS) { println("Skipping Windows-specific test on non-Windows platform") return } @@ -141,7 +141,7 @@ class WindowsVideoPlayerStateTest { @Test fun testErrorHandling() { // Skip test if not running on Windows - if (!Platform.isWindows()) { + if (CurrentPlatform.os != CurrentPlatform.OS.WINDOWS) { println("Skipping Windows-specific test on non-Windows platform") return }