diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt index e9aef095..fe7c2dff 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt @@ -4,14 +4,27 @@ import java.nio.ByteBuffer internal fun calculateFrameHash( buffer: ByteBuffer, - pixelCount: Int, + width: Int, + height: Int, + rowBytes: Int, ): Int { - if (pixelCount <= 0) return 0 + if (width <= 0 || height <= 0) return 0 + val pixelCount = width * height 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) + var step = if (pixelCount <= 200) 1 else pixelCount / 200 + // A step that is a multiple of the width would pin every sample to the same x column + // (e.g. 720x400 → step 1440 → x always 0); with a static edge (letterboxing, dark scene + // border) the hash would never change and the dedup would freeze the video. + if (width > 1 && step % width == 0) step++ + var i = 0 + while (i < pixelCount) { + // Map the linear sample index to its real byte offset, honoring row padding + // (rowBytes may exceed width*4) so we sample true pixels rather than padding bytes. + val x = i % width + val y = i / width + hash = 31 * hash + buffer.getInt(y * rowBytes + x * 4) + i += step } return hash } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt index 6f5cfac5..f21427d6 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt @@ -65,6 +65,8 @@ internal object MacNativeBridge { @JvmStatic external fun nGetFrameHeight(handle: Long): Int + @JvmStatic external fun nGetDisplayAspectRatio(handle: Long): Double + @JvmStatic external fun nSetOutputSize( handle: Long, width: Int, diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt index cb636c16..981f356b 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt @@ -1,6 +1,9 @@ package io.github.kdroidfilter.composemediaplayer.mac -import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap @@ -16,75 +19,179 @@ import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType import org.jetbrains.skia.ImageInfo import java.io.File +import java.net.URI import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong -import kotlin.math.abs +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write internal val macLogger = TaggedLogger("MacVideoPlayerState") /** - * MacVideoPlayerState handles the native Mac video player state. + * Cadence (ms) for polling the AVPlayer clock to advance the timeline. ~200 ms keeps the slider + * smooth without spamming the native bridge; mirrors AVPlayer's addPeriodicTimeObserver usage. + */ +private const val POSITION_POLL_INTERVAL_MS = 200L + +/** + * macOS implementation of the video player state. + * + * Handles media playback through a native AVFoundation player (via [MacNativeBridge]). * - * This implementation uses a native video player via MacNativeBridge. + * The architecture intentionally mirrors the Windows implementation 1:1 so the two platforms + * behave identically: a producer/consumer coroutine pair drives frames through a single-slot + * drop-oldest [Channel], frames are deduplicated by content hash, triple-buffered Skia bitmaps + * avoid tearing, and the playback position is derived from each frame's timestamp rather than a + * separate polling loop. The one place macOS deliberately diverges is the aspect ratio: it uses + * AVFoundation's display aspect ratio (pixel-aspect-ratio / clean-aperture corrected) so + * anamorphic content renders correctly — see [displayAspectRatio]. */ class MacVideoPlayerState : VideoPlayerState { - // Main state variables - // AtomicLong allows lock-free reads of the native pointer from the frame hot path + /** Native AVFoundation player. AtomicLong allows lock-free reads from the frame hot path. */ private val playerPtrAtomic = AtomicLong(0L) private val playerPtr: Long get() = playerPtrAtomic.get() - // Serial dispatcher for frame processing — ensures only one frame is processed at a time - private val frameDispatcher = Dispatchers.Default.limitedParallelism(1) - private val _currentFrameState = MutableStateFlow(null) - internal val currentFrameState: State = mutableStateOf(null) - private var skiaBitmapWidth: Int = 0 - private var skiaBitmapHeight: Int = 0 - private var skiaBitmapA: Bitmap? = null - private var skiaBitmapB: Bitmap? = null - private var nextSkiaBitmapA: Boolean = true + /** Coroutine scope for all async operations. Mirrors Windows (Dispatchers.Default). */ + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - // Surface display size (pixels) — used to scale native output resolution - private var surfaceWidth = 0 - private var surfaceHeight = 0 - private val isResizing = AtomicBoolean(false) - private var resizeJob: Job? = null + /** Whether media has been loaded. */ + private var _hasMedia by mutableStateOf(false) + override val hasMedia get() = _hasMedia + + /** Whether media is currently playing. */ + private var _isPlaying by mutableStateOf(false) + override val isPlaying get() = _isPlaying + + /** Whether the user has intentionally paused the video. */ + private var userPaused = false + + /** Deferred completed when the native player has been created. */ + private val initReady = CompletableDeferred() + + /** Flag to track if the player is being disposed. */ + private val isDisposing = AtomicBoolean(false) + + /** Current volume level (0.0 to 1.0). */ + private var _volume by mutableStateOf(1f) + override var volume: Float + get() = _volume + set(value) { + val newVolume = value.coerceIn(0f, 1f) + if (_volume != newVolume) { + _volume = newVolume + scope.launch { + mediaOperationMutex.withLock { + playerPtr.takeIf { it != 0L }?.let { ptr -> + try { + MacNativeBridge.nSetVolume(ptr, newVolume) + } catch (e: Exception) { + if (e is CancellationException) throw e + setError("Error updating volume: ${e.message}") + } + } + } + } + } + } + + private var _currentTime by mutableStateOf(0.0) + private var _duration by mutableStateOf(0.0) + private var _progress by mutableStateOf(0f) + override var sliderPos: Float + get() = _progress * 1000f + set(value) { + _progress = (value / 1000f).coerceIn(0f, 1f) + } + private var _userDragging by mutableStateOf(false) + override var userDragging: Boolean + get() = _userDragging + set(value) { + _userDragging = value + } + private var _loop by mutableStateOf(false) + override var loop: Boolean + get() = _loop + set(value) { + _loop = value + } - // Background worker threads 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 - private var videoFrameRate: Float = 0.0f - private var screenRefreshRate: Float = 0.0f - private var captureFrameRate: Float = 0.0f - - // UI State (Main thread) - 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 onPlaybackEnded: (() -> Unit)? = null override var onRestart: (() -> Unit)? = null - override var error: VideoPlayerError? by mutableStateOf(null) - override var subtitlesEnabled: Boolean by mutableStateOf(false) + + private var _playbackSpeed by mutableStateOf(1.0f) + override var playbackSpeed: Float + get() = _playbackSpeed + set(value) { + val newSpeed = value.coerceIn(VideoPlayerState.MIN_PLAYBACK_SPEED, VideoPlayerState.MAX_PLAYBACK_SPEED) + if (_playbackSpeed != newSpeed) { + _playbackSpeed = newSpeed + scope.launch { + mediaOperationMutex.withLock { + playerPtr.takeIf { it != 0L }?.let { ptr -> + try { + MacNativeBridge.nSetPlaybackSpeed(ptr, newSpeed) + } catch (e: Exception) { + if (e is CancellationException) throw e + setError("Error updating playback speed: ${e.message}") + } + } + } + } + } + } + + private var _error: VideoPlayerError? by mutableStateOf(null) + override val error get() = _error + + override fun clearError() { + // _error is snapshot state (thread-safe); set it directly. A runBlocking { withContext(Main) } + // here would self-deadlock when called from the AWT/Compose UI thread. + _error = null + } + + // Current frame management + private var _currentFrame: Bitmap? by mutableStateOf(null) + private val bitmapLock = ReentrantReadWriteLock() + internal val currentFrameState = mutableStateOf(null) + + // Aspect ratio — driven by the live frame (see syncAspectRatioToFrame). + private var _aspectRatio by mutableStateOf(16f / 9f) + override val aspectRatio: Float get() = _aspectRatio + + // Metadata and UI state + override val metadata: VideoMetadata = VideoMetadata() + override var subtitlesEnabled by mutableStateOf(false) override var currentSubtitleTrack: SubtitleTrack? by mutableStateOf(null) - override val availableSubtitleTracks: MutableList = mutableListOf() + override val availableSubtitleTracks = mutableListOf() override var subtitleTextStyle: TextStyle by mutableStateOf( TextStyle( color = Color.White, @@ -94,244 +201,143 @@ class MacVideoPlayerState : VideoPlayerState { ), ) 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 + override var isLoading by mutableStateOf(false) + private set + override val positionText: String get() = formatTime(_currentTime) + override val durationText: String get() = formatTime(_duration) + override val currentTime: Double get() = _currentTime + override val duration: Double get() = _duration - // Non-blocking text properties - private val _positionText = mutableStateOf("00:00") - override val positionText: String get() = _positionText.value + // Fullscreen state + override var isFullscreen by mutableStateOf(false) - private val _durationText = mutableStateOf("00:00") - override val durationText: String get() = _durationText.value + // Video properties (decoded frame dimensions, possibly scaled to the surface) + private var videoWidth by mutableStateOf(0) + private var videoHeight by mutableStateOf(0) - override val currentTime: Double - get() = - runBlocking { - if (hasMedia) getPositionSafely() else 0.0 - } + // Surface display size (pixels) — used to scale native output resolution + private var surfaceWidth = 0 + private var surfaceHeight = 0 - override val duration: Double - get() = - runBlocking { - if (hasMedia) getDurationSafely() else 0.0 - } + // Frame rate info + private var videoFrameRate: Float = 0f + private var screenRefreshRate: Float = 0f + private var captureFrameRate: Float = 0f - // Non-blocking aspect ratio property - private val _aspectRatio = mutableStateOf(16f / 9f) - override val aspectRatio: Float get() = _aspectRatio.value + // Synchronization + private val mediaOperationMutex = Mutex() + private val isResizing = AtomicBoolean(false) + private var videoJob: Job? = null + private var resizeJob: Job? = null - // Player settings - // Volume variable is stored independently so it can always be modified. - private val _volumeState = mutableStateOf(1.0f) - override var volume: Float - get() = _volumeState.value - set(value) { - val newValue = value.coerceIn(0f, 1f) - if (_volumeState.value != newValue) { - _volumeState.value = newValue - // Launch a coroutine to apply the volume if the native player is available. - ioScope.launch { - applyVolume() - } - } - } + // Seek coalescing: rapid slider drags overwrite the target; only the latest value is actually + // seeked. seekInFlight acts as the "a loop is draining the target" claim. The target is stored + // as microseconds (seconds * 1_000_000) so it fits a Long; Long.MIN_VALUE is the empty sentinel. + private val pendingSeekTarget = AtomicLong(Long.MIN_VALUE) + private val seekInFlight = AtomicBoolean(false) + + // Serializes native frame access (nLockFrame/nUnlockFrame, held by the producer) and nSeekTo + // (held by the seek flow), so a seek never runs concurrently with a frame read. + private val videoReaderMutex = Mutex() + private val isSeeking = AtomicBoolean(false) + + // Open coordination: openToken makes a newer open supersede an older one; isOpening lets + // callers (e.g. play()'s slow path) tell that an open is already in flight. + private val openToken = AtomicLong(0L) + private val isOpening = AtomicBoolean(false) + + // Frame channel: one slot, drop-oldest. With triple-buffering on the producer side, overflow + // simply means the consumer was slow — safe to drop. + private val frameChannel = + Channel( + capacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private data class FrameData( + val bitmap: Bitmap, + val timestamp: Double, + ) - // Playback speed control - private val _playbackSpeedState = mutableStateOf(1.0f) - override var playbackSpeed: Float - get() = _playbackSpeedState.value - set(value) { - val newValue = value.coerceIn(VideoPlayerState.MIN_PLAYBACK_SPEED, VideoPlayerState.MAX_PLAYBACK_SPEED) - if (_playbackSpeedState.value != newValue) { - _playbackSpeedState.value = newValue - // Launch a coroutine to apply the playback speed if the native player is available. - ioScope.launch { - applyPlaybackSpeed() - } - } - } + // Triple-buffering for zero-copy frame rendering: the consumer may still be driving a frame + // onto Compose (via currentFrameState) when the producer writes the next frame. Two bitmaps is + // racy — with three, the buffer the producer writes is distinct from both the one currently + // bound to ImageBitmap and the one Compose just finished. + private val skiaBitmaps = arrayOfNulls(3) + private var nextBitmapIndex: Int = 0 + + // Bitmap most recently handed to frameChannel (producer-thread only). Excluded as a write + // target together with the displayed bitmap: with the drop-oldest channel the consumer can + // lag a full slot behind, and blind round-robin would then write into the bitmap currently + // bound to Compose (or sitting undelivered in the channel) and tear on screen. + private var lastSentBitmap: Bitmap? = null + @Volatile + private var lastFrameHash: Int = Int.MIN_VALUE + private var skiaBitmapWidth: Int = 0 + private var skiaBitmapHeight: Int = 0 - private val updateInterval: Long - get() = - if (captureFrameRate > 0) { - (1000.0f / captureFrameRate).toLong() - } else { - 33L // Default value (in ms) if no valid capture rate is provided - } + // Bitmaps awaiting safe closure. When the video resolution changes mid-stream (HLS adaptive + // bitrate) the old buffers may still be read by Compose via currentFrameState. We defer close() + // by a few consumed frames so Compose has swapped to the new bitmap first. + private data class PendingCloseBitmap(val bitmap: Bitmap, var framesLeft: Int) + private val pendingCloseBitmaps = ArrayDeque() + private val pendingCloseGraceFrames: Int = 4 - // Buffering detection constants - private val bufferingCheckInterval = 200L // Increased from 100ms to reduce CPU usage - private val bufferingTimeoutThreshold = 500L + // Adaptive frame interval (ms) based on the video's frame rate — the producer polls the native + // "latest frame" at this cadence (AVFoundation pushes frames; it has no blocking read). + private var frameIntervalMs: Long = 16L - init { - macLogger.d { "Initializing video player" } - ioScope.launch { - initPlayer() - startUIUpdateJob() - } - } + /** Flag to track whether we've read at least one frame while paused (for the paused thumbnail). */ + private val initialFrameRead = AtomicBoolean(false) - /** - * Starts a job to update UI state based on frame updates. This is the only - * job that touches the main thread. - */ - @OptIn(FlowPreview::class) - private fun startUIUpdateJob() { - uiUpdateJob?.cancel() - uiUpdateJob = - ioScope.launch { - _currentFrameState.debounce(1).collect { newFrame -> - ensureActive() // Checks that the coroutine is still active - withContext(Dispatchers.Main) { - (currentFrameState as MutableState).value = newFrame - } - } - } - } + // Last opened URI, for play()-without-media reload. + private var lastUri: String? = null - /** Initializes the native video player on the IO thread. */ - private suspend fun initPlayer() = - ioScope - .launch { - macLogger.d { "initPlayer() - Creating native player" } - try { - val ptr = MacNativeBridge.nCreatePlayer() - if (ptr != 0L) { - playerPtrAtomic.set(ptr) - macLogger.d { "Native player created successfully" } - applyVolume() - applyPlaybackSpeed() - } else { - macLogger.e { "Error: Failed to create native player" } - withContext(Dispatchers.Main) { - error = VideoPlayerError.UnknownError("Failed to create native player") - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Exception in initPlayer: ${e.message}" } - withContext(Dispatchers.Main) { - error = VideoPlayerError.UnknownError("Failed to initialize player: ${e.message}") - } + init { + macLogger.d { "Initializing video player" } + scope.launch { + try { + val ptr = MacNativeBridge.nCreatePlayer() + if (ptr == 0L) { + setError("Failed to create native player") + return@launch } - }.join() - - /** Updates the frame rate information from the native player. */ - private suspend fun updateFrameRateInfo() { - macLogger.d { "updateFrameRateInfo()" } - val ptr = playerPtr - if (ptr == 0L) return - - try { - videoFrameRate = MacNativeBridge.nGetVideoFrameRate(ptr) - screenRefreshRate = MacNativeBridge.nGetScreenRefreshRate(ptr) - captureFrameRate = MacNativeBridge.nGetCaptureFrameRate(ptr) - macLogger.d { - "Frame Rates - Video: $videoFrameRate, Screen: $screenRefreshRate, Capture: $captureFrameRate" + playerPtrAtomic.set(ptr) + applyVolume() + applyPlaybackSpeed() + initReady.complete(Unit) + } catch (e: Exception) { + initReady.completeExceptionally(e) + setError("Exception during initialization: ${e.message}") } - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error updating frame rate info: ${e.message}" } } } - // Check if this is a local file that doesn't exist - // This handles both URIs with a "file:" scheme and simple filenames without a scheme, with or without authority. - // Uses File directly to support paths with spaces or non-ASCII characters that URI.create() rejects. - private fun checkExistsIfLocalFile(uri: String): Boolean { - val schemeDelimiter = uri.indexOf("://") - val scheme = if (schemeDelimiter >= 0) uri.substring(0, schemeDelimiter) else "" - return when (scheme) { - "", "file" -> { - val path = if (scheme == "file") uri.removePrefix("file://") else uri - File(path).exists() - } - else -> true // Network URI — assume reachable - } - } + // --------------------------------------------------------------------------------------------- + // Open + // --------------------------------------------------------------------------------------------- override fun openUri( uri: String, initializeplayerState: InitialPlayerState, ) { - macLogger.d { "openUri() - Opening URI: $uri, initializeplayerState: $initializeplayerState" } - - lastUri = uri - - // Check if this is a local file that doesn't exist - if (!checkExistsIfLocalFile(uri)) { - macLogger.e { "File does not exist: $uri" } - setPlayerError(VideoPlayerError.SourceError("File not found: $uri")) + if (isDisposing.get()) { + macLogger.w { "Ignoring openUri call - player is being disposed" } return } - // Update UI state first - ioScope.launch { - withContext(Dispatchers.Main) { - isLoading = true - error = null // Clear any previous errors only if we got this far - playbackSpeed = 1.0f - } + lastUri = uri + playbackSpeed = 1.0f - // Ensure heavy operations are performed in the background + scope.launch { try { - // Stop and clean up any existing playback - if (hasMedia) { - cleanupCurrentPlayback() - } - - // Ensure player is initialized in the background - ensurePlayerInitialized() - - // Open URI on IO thread and capture result - val result = openMediaUri(uri) - - if (result) { - // Launch parallel background tasks - coroutineScope { - launch { updateFrameRateInfo() } - launch { updateMetadata() } - } - - // Scale output to match display surface if size is already known - if (surfaceWidth > 0 && surfaceHeight > 0) { - applyOutputScaling() - } - - // Update UI state on main thread - withContext(Dispatchers.Main) { - hasMedia = true - isLoading = false - // Set isPlaying based on the initializeplayerState parameter - isPlaying = initializeplayerState == InitialPlayerState.PLAY - } - - // Start background processes for frame updates - startFrameUpdates() - - // First frame update in the background - updateFrameAsync() - - // Start buffering check in the background - startBufferingCheck() - - // Start playback if needed - in the background - if (isPlaying) { - playInBackground() - } - } else { - macLogger.e { "Failed to open URI" } - // Use withContext directly since we're already in a suspend function - withContext(Dispatchers.Main) { - isLoading = false - error = VideoPlayerError.SourceError("Failed to open media source") - } - } + withTimeout(10_000) { initReady.await() } + openUriInternal(uri, initializeplayerState) + } catch (_: TimeoutCancellationException) { + setError("Player initialization timed out after 10 s.") } catch (e: Exception) { if (e is CancellationException) throw e - macLogger.e { "openUri() - Exception: ${e.message}" } - handleError(e) + setError("Error while waiting for initialization: ${e.message}") } } } @@ -343,91 +349,141 @@ class MacVideoPlayerState : VideoPlayerState { openUri(file.file.path, initializeplayerState) } - /** Cleans up current playback state. */ - private suspend fun cleanupCurrentPlayback() { - macLogger.d { "cleanupCurrentPlayback() - Cleaning up current playback" } - pauseInBackground() - stopFrameUpdates() - stopBufferingCheck() - - val ptrToDispose = - withContext(frameDispatcher) { - playerPtrAtomic.getAndSet(0L) - } + private fun openUriInternal( + uri: String, + initializeplayerState: InitialPlayerState, + ) { + scope.launch { + if (isDisposing.get()) return@launch - // Release resources outside of the mutex lock - if (ptrToDispose != 0L) { + // A newer open supersedes any older one still running its (unlocked) polling phase. + val myToken = openToken.incrementAndGet() + isOpening.set(true) + var ptr = 0L try { - MacNativeBridge.nDisposePlayer(ptrToDispose) - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error disposing player: ${e.message}" } - } - } - } + // Phase 1 (locked): stop current playback, reset state, hand the URI to native. + mediaOperationMutex.withLock { + if (isDisposing.get() || myToken != openToken.get()) return@withLock + isLoading = true - /** Ensures the player is initialized. */ - private suspend fun ensurePlayerInitialized() { - macLogger.d { "ensurePlayerInitialized() - Ensuring player is initialized" } - if (!playerScope.isActive) { - playerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - } + val p = playerPtr + if (p == 0L) { + setError("Native player is null") + return@withLock + } - if (playerPtr == 0L) { - val ptr = MacNativeBridge.nCreatePlayer() - if (ptr != 0L) { - if (!playerPtrAtomic.compareAndSet(0L, ptr)) { - // Another coroutine already initialized the player; discard ours - MacNativeBridge.nDisposePlayer(ptr) - } else { - applyVolume() - applyPlaybackSpeed() + if (_isPlaying) { + MacNativeBridge.nPause(p) + _isPlaying = false + delay(50) + } + + videoJob?.cancelAndJoin() + releaseAllResources() + + _currentTime = 0.0 + _progress = 0f + _duration = 0.0 + resetMetadata() + _hasMedia = false + userPaused = false + initialFrameRead.set(false) + // Discard any seek latched against the previous media; if the in-flight seek + // loop only drained it after the new media finished opening, the new video + // would jump to the old target. + pendingSeekTarget.set(Long.MIN_VALUE) + + // Validate local files before handing off to the native layer. + if (!checkExistsIfLocalFile(uri)) { + setError("File not found: $uri") + return@withLock + } + + MacNativeBridge.nOpenUri(p, uri) + ptr = p } - } else { - throw IllegalStateException("Failed to create native player") - } - } - } - /** Opens media URI and returns a success flag. */ - private suspend fun openMediaUri(uri: String): Boolean { - macLogger.d { "openMediaUri() - Opening URI: $uri" } - val ptr = playerPtr - if (ptr == 0L) return false - - // Check if file exists (for local files) - // This handles both URIs with file:// scheme and simple filenames without a scheme - if (!checkExistsIfLocalFile(uri)) { - macLogger.e { "File does not exist: $uri" } - // Use setPlayerError to ensure the error is set synchronously - setPlayerError(VideoPlayerError.SourceError("File not found: $uri")) - return false - } + if (ptr == 0L || isDisposing.get() || myToken != openToken.get()) return@launch + + // Phase 2 (UNLOCKED): AVFoundation loads asynchronously, so these polls can take + // several seconds. Running them outside mediaOperationMutex keeps volume/other + // operations responsive during a slow open. + pollDimensionsUntilReady(ptr) + val resolvedDuration = pollDurationUntilReady(ptr) + if (isDisposing.get() || myToken != openToken.get()) return@launch + + // Phase 3 (locked): finalize metadata and start the pipeline. + mediaOperationMutex.withLock { + if (isDisposing.get() || myToken != openToken.get()) return@withLock + try { + val w = MacNativeBridge.nGetFrameWidth(ptr) + val h = MacNativeBridge.nGetFrameHeight(ptr) + if (w <= 0 || h <= 0) { + setError("Failed to retrieve video size") + return@withLock + } + videoWidth = w + videoHeight = h + + // Scale output to match the display surface (saves memory for 4K+ video). + if (surfaceWidth > 0 && surfaceHeight > 0) { + videoReaderMutex.withLock { + MacNativeBridge.nSetOutputSize(ptr, surfaceWidth, surfaceHeight) + val sw = MacNativeBridge.nGetFrameWidth(ptr) + val sh = MacNativeBridge.nGetFrameHeight(ptr) + if (sw > 0 && sh > 0) { + videoWidth = sw + videoHeight = sh + } + } + } - return try { - // Open video asynchronously - MacNativeBridge.nOpenUri(ptr, uri) + // pollDurationUntilReady already resolved this; live streams settle to 0, + // and observePosition() backfills it if it arrives even later. + _duration = resolvedDuration - // Instead of directly calling `updateMetadata()`, - // we poll until valid dimensions are available - pollDimensionsUntilReady(ptr) + updateMetadata() + updateFrameRateInfo() - // Once dimensions are retrieved, call updateMetadata() - updateMetadata() + // Adaptive polling interval from the video frame rate, like Windows. + val rate = if (captureFrameRate > 0f) captureFrameRate else videoFrameRate + frameIntervalMs = if (rate > 0f) (1000f / rate).toLong().coerceIn(8L, 50L) else 16L - true - } catch (e: Exception) { - macLogger.e { "Failed to open URI: ${e.message}" } - // Use setPlayerError to ensure the error is set synchronously - setPlayerError(VideoPlayerError.SourceError("Error opening media: ${e.message}")) - false + _hasMedia = true + + val startPlayback = initializeplayerState == InitialPlayerState.PLAY + if (!startPlayback) { + userPaused = true + initialFrameRead.set(false) + isLoading = false + } + if (startPlayback) { + MacNativeBridge.nPlay(ptr) + } + _isPlaying = startPlayback + + videoJob = startVideoPipeline() + } catch (e: Exception) { + if (e is CancellationException) throw e + setError("Error while opening media: ${e.message}") + _hasMedia = false + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + setError("Error while opening media: ${e.message}") + _hasMedia = false + } finally { + // Only clear the flag if a newer open hasn't taken over. + if (myToken == openToken.get()) isOpening.set(false) + if (!_hasMedia) isLoading = false + } } } /** - * Loops several times (every 250 ms) until width/height - * are no longer zero. If dimensions are still zero after - * a specified number of attempts, stop waiting. + * Loops several times (every 250 ms) until width/height are no longer zero, or until + * [maxAttempts] is reached. */ private suspend fun pollDimensionsUntilReady( ptr: Long, @@ -436,555 +492,828 @@ class MacVideoPlayerState : VideoPlayerState { for (attempt in 1..maxAttempts) { val width = MacNativeBridge.nGetFrameWidth(ptr) val height = MacNativeBridge.nGetFrameHeight(ptr) - if (width > 0 && height > 0) { macLogger.d { "Dimensions validated (w=$width, h=$height) after $attempt attempts" } return } - macLogger.d { "Dimensions not ready yet (attempt $attempt/$maxAttempts), waiting..." } delay(250) } macLogger.e { "Unable to retrieve valid dimensions after $maxAttempts attempts" } } - /** Updates the metadata from the native player. */ - private suspend fun updateMetadata() { - macLogger.d { "updateMetadata()" } + /** + * Reads the media duration from the native player, normalizing AVFoundation's states: + * - `> 0.0` — a finite duration in seconds + * - `0.0` — not available yet (AVPlayerItem.duration still indefinite → NaN while loading) + * - `-1.0` — a live / indefinite stream (no finite duration) + */ + private fun readDuration(ptr: Long): Double { + val raw = MacNativeBridge.nGetVideoDuration(ptr) + return when { + raw < 0.0 -> -1.0 // live-stream sentinel from native getDuration() + raw.isNaN() -> 0.0 // not loaded yet + else -> raw // 0.0 (not ready) or a real duration + } + } + + /** + * Polls [readDuration] until a finite duration is available, the stream is detected as live, + * or [maxAttempts] is reached. Mirrors [pollDimensionsUntilReady]. Returns the duration in + * seconds, or 0.0 for live / still-unknown streams. + */ + private suspend fun pollDurationUntilReady( + ptr: Long, + maxAttempts: Int = 20, + ): Double { + for (attempt in 1..maxAttempts) { + val d = readDuration(ptr) + if (d > 0.0) { + macLogger.d { "Duration resolved ($d s) after $attempt attempts" } + return d + } + if (d < 0.0) return 0.0 // live stream — no finite total time + delay(100) + } + macLogger.w { "Duration still unavailable after $maxAttempts attempts" } + return 0.0 + } + + // Handles plain paths and every file-URI shape (file:/p, file://p, file:///p — naive + // "file://" prefix-stripping rejected the single-slash form java.net.URI produces); + // URI parsing also undoes percent-encoding so paths with escaped characters are checked + // correctly. Network URIs are assumed reachable. + private fun checkExistsIfLocalFile(uri: String): Boolean = + try { + val parsed = URI(uri) + when { + parsed.scheme == null -> File(uri).exists() + parsed.scheme.equals("file", ignoreCase = true) -> + // path is null for opaque URIs like "file:relative/path" — malformed for us. + parsed.path?.let { File(it).exists() } ?: false + else -> true + } + } catch (_: Exception) { + // Not parseable as a URI (e.g. a plain path with spaces) — treat as a local path. + File(uri).exists() + } + + // --------------------------------------------------------------------------------------------- + // Aspect ratio (the deliberate divergence from Windows — display-AR correct) + // --------------------------------------------------------------------------------------------- + + /** + * Returns the aspect ratio the video should be displayed at. + * + * Prefers the display aspect ratio reported by AVFoundation ([MacNativeBridge.nGetDisplayAspectRatio], + * i.e. `presentationSize`), which has the pixel aspect ratio and clean aperture applied. Anamorphic / + * non-square-pixel videos have a display aspect ratio that differs from the raw decoded pixel-buffer + * dimensions; drawing the raw bitmap into a Canvas sized with this display ratio rescales the pixels + * back to their intended geometry. Falls back to the raw frame ratio when unavailable. + */ + private fun displayAspectRatio(width: Int, height: Int): Float { + val ptr = playerPtr + val displayAspect = if (ptr != 0L) MacNativeBridge.nGetDisplayAspectRatio(ptr) else 0.0 + return if (displayAspect > 0.0) displayAspect.toFloat() else width.toFloat() / height.toFloat() + } + + /** + * Aligns the displayed aspect ratio (and reported dimensions) with the live frame. Called on + * every published frame; the guards keep it a no-op unless a value actually changed. + */ + private fun syncAspectRatioToFrame(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + val frameAspect = displayAspectRatio(width, height) + if (kotlin.math.abs(frameAspect - _aspectRatio) > 0.001f) { + _aspectRatio = frameAspect + } + if (metadata.width != width) metadata.width = width + if (metadata.height != height) metadata.height = height + } + + // --------------------------------------------------------------------------------------------- + // Metadata + // --------------------------------------------------------------------------------------------- + + private fun resetMetadata() { + 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 + } + + private fun updateMetadata() { val ptr = playerPtr if (ptr == 0L) return - try { val width = MacNativeBridge.nGetFrameWidth(ptr) val height = MacNativeBridge.nGetFrameHeight(ptr) - val duration = (MacNativeBridge.nGetVideoDuration(ptr) * 1000).toLong() - val frameRate = MacNativeBridge.nGetVideoFrameRate(ptr) + // Use the already-resolved _duration (pollDurationUntilReady ran before this) rather + // than re-reading the raw native value, which is often still NaN here and would pin + // metadata.duration to null forever. observePosition() backfills it if it lands later. + metadata.duration = (_duration * 1000).toLong().takeIf { it > 0 } + metadata.width = width + metadata.height = height + metadata.frameRate = MacNativeBridge.nGetVideoFrameRate(ptr) + metadata.title = MacNativeBridge.nGetVideoTitle(ptr) + metadata.bitrate = MacNativeBridge.nGetVideoBitrate(ptr) + metadata.mimeType = MacNativeBridge.nGetVideoMimeType(ptr) + metadata.audioChannels = MacNativeBridge.nGetAudioChannels(ptr).takeIf { it != 0 } + metadata.audioSampleRate = MacNativeBridge.nGetAudioSampleRate(ptr).takeIf { it != 0 } - // Calculate aspect ratio - val newAspectRatio = - if (width > 0 && height > 0) { - width.toFloat() / height.toFloat() - } else { - // Instead of forcing 16f/9f, don’t change the aspect if the video is not ready yet. - // For example, we can keep the previous aspect ratio: - _aspectRatio.value - } - - // Get additional metadata - val title = MacNativeBridge.nGetVideoTitle(ptr) - val bitrate = MacNativeBridge.nGetVideoBitrate(ptr) - val mimeType = MacNativeBridge.nGetVideoMimeType(ptr) - val audioChannels = MacNativeBridge.nGetAudioChannels(ptr) - val audioSampleRate = MacNativeBridge.nGetAudioSampleRate(ptr) - - withContext(Dispatchers.Main) { - // Update metadata - 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 - - // Update the aspect ratio only if width/height are valid - _aspectRatio.value = newAspectRatio + if (width > 0 && height > 0) { + _aspectRatio = displayAspectRatio(width, height) } - - macLogger.d { "Metadata updated: $metadata" } } catch (e: Exception) { if (e is CancellationException) throw e macLogger.e { "Error updating metadata: ${e.message}" } } } - /** Starts periodic frame updates on a background thread. */ - private fun startFrameUpdates() { - macLogger.d { "startFrameUpdates() - Starting frame updates" } - stopFrameUpdates() - frameUpdateJob = - ioScope.launch { - while (isActive) { - ensureActive() // Check if coroutine is still active - updateFrameAsync() - if (!userDragging) { - updatePositionAsync() - } - delay(updateInterval) - } - } + private fun updateFrameRateInfo() { + val ptr = playerPtr + if (ptr == 0L) return + try { + videoFrameRate = MacNativeBridge.nGetVideoFrameRate(ptr) + screenRefreshRate = MacNativeBridge.nGetScreenRefreshRate(ptr) + captureFrameRate = MacNativeBridge.nGetCaptureFrameRate(ptr) + } catch (e: Exception) { + if (e is CancellationException) throw e + macLogger.e { "Error updating frame rate info: ${e.message}" } + } } - /** Stops periodic frame updates. */ - private fun stopFrameUpdates() { - macLogger.d { "stopFrameUpdates() - Stopping frame updates" } - frameUpdateJob?.cancel() - frameUpdateJob = null - } + // --------------------------------------------------------------------------------------------- + // Frame pipeline (producer / consumer) — mirrors Windows + // --------------------------------------------------------------------------------------------- - /** Starts periodic buffering detection on a background thread. */ - private fun startBufferingCheck() { - macLogger.d { "startBufferingCheck() - Starting buffering detection" } - stopBufferingCheck() - bufferingCheckJob = - ioScope.launch { - while (isActive) { - ensureActive() // Check if coroutine is still active - checkBufferingState() - delay(bufferingCheckInterval) - } - } + private fun startVideoPipeline(): Job = scope.launch { + launch { produceFrames() } + launch { consumeFrames() } + launch { observePosition() } } - /** Checks if the media is currently buffering. */ - private suspend fun checkBufferingState() { - if (isPlaying && !isLoading) { - val currentTime = System.currentTimeMillis() - val timeSinceLastFrame = currentTime - lastFrameUpdateTime - - if (timeSinceLastFrame > bufferingTimeoutThreshold) { - macLogger.d { "Buffering detected: $timeSinceLastFrame ms since last frame update" } - withContext(Dispatchers.Main) { - isLoading = true + /** + * Drives the timeline from the AVPlayer clock, independent of the frame pipeline. + * + * Windows can ride the timeline on consumed frames because Media Foundation couples its frame + * clock to GetMediaPosition. AVFoundation does not: nLockFrame returns the same CVPixelBuffer + * until a genuinely new one is ready, so the content-hash dedup in [processOneFrame] drops + * repeats and the frame-derived position in [consumeFrames] stalls (the slider freezes on + * low-motion content even though playback continues). Polling nGetCurrentTime on a fixed + * cadence keeps the position advancing smoothly regardless of frame delivery. + */ + private suspend fun observePosition() { + while (scope.isActive && _hasMedia && !isDisposing.get()) { + val ptr = playerPtr + if (ptr != 0L) { + // Backfill a duration that arrived after open (e.g. HLS VOD) regardless of play + // state, so the total-time label appears even when the media was opened paused. + // metadata.duration is what consumers read for the total-time label. + if (_duration <= 0.0) { + val d = readDuration(ptr) + if (d > 0.0) { + _duration = d + metadata.duration = (d * 1000).toLong().takeIf { it > 0 } + } + } + // Advance the timeline only while genuinely playing and not seeking/dragging, so + // we never clobber the optimistic seek/drag UI (seekFinished() reads sliderPos, + // backed by _progress). seekInFlight covers the whole async-seek window, not just + // the brief native-seek (isSeeking) portion. + if (_isPlaying && !_userDragging && !isSeeking.get() && !seekInFlight.get() && _duration > 0.0) { + val pos = MacNativeBridge.nGetCurrentTime(ptr) + if (pos >= 0.0) { + _currentTime = pos + _progress = (pos / _duration).toFloat().coerceIn(0f, 1f) + } } } + delay(POSITION_POLL_INTERVAL_MS) } } - /** Stops the buffering detection job. */ - private fun stopBufferingCheck() { - macLogger.d { "stopBufferingCheck() - Stopping buffering detection" } - bufferingCheckJob?.cancel() - bufferingCheckJob = null - } + private suspend fun produceFrames() { + while (scope.isActive && _hasMedia && !isDisposing.get()) { + val ptr = playerPtr + if (ptr == 0L) break + + // End-of-playback: AVFoundation fires AVPlayerItemDidPlayToEndTime, surfaced as a + // one-shot flag. Only consume/act on it while genuinely playing and not mid-seek or + // mid-drag — otherwise a stray flag observed during a seek or paused thumbnail read + // could be swallowed or trigger a spurious end (a drag always ends in a seek, whose + // performSeek() drains any flag that became stale). Mirrors the Windows IsEOF branch. + if (_isPlaying && !_userDragging && !isSeeking.get() && !seekInFlight.get() && + MacNativeBridge.nConsumeDidPlayToEnd(ptr) + ) { + if (_duration <= 0.0) { + // Live stream — wait and continue. + delay(1000) + continue + } else if (loop) { + try { + userPaused = false + initialFrameRead.set(false) + lastFrameHash = Int.MIN_VALUE + // _isPlaying stays true, so performSeek() resumes native playback via its + // own nPlay; no explicit play() needed here. + seekTo(0f) + onRestart?.invoke() + } catch (e: Exception) { + if (e is CancellationException) throw e + setError("Error during loop restart: ${e.message}") + } + // Don't fall through to read the stale end-of-stream frame this iteration. + continue + } else { + // Stop the play state synchronously first so observePosition (a sibling + // coroutine) can't immediately un-snap the slider, then snap to 100% and pause + // the native player directly (don't rely on pause()'s _isPlaying precondition). + _isPlaying = false + _currentTime = _duration + _progress = 1f + userPaused = true + initialFrameRead.set(false) + try { + MacNativeBridge.nPause(ptr) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + onPlaybackEnded?.invoke() + // Keep the producer alive: consumeFrames/observePosition still run, so + // videoJob stays active and resumePlayback()/performSeek() would never + // relaunch the pipeline if we exited here. Falling through to + // waitForPlaybackState parks this coroutine cheaply until play() resumes. + continue + } + } - /** Updates the current video frame on a background thread. */ - private suspend fun updateFrameAsync() { - withContext(frameDispatcher) { try { - val ptr = playerPtr - if (ptr == 0L) return@withContext - - // Lock the CVPixelBuffer directly — eliminates the Swift-side memcpy. - // outInfo = [width, height, bytesPerRow] - val outInfo = IntArray(3) - val frameAddress = MacNativeBridge.nLockFrame(ptr, outInfo) - if (frameAddress == 0L) return@withContext - - val width = outInfo[0] - val height = outInfo[1] - val srcBytesPerRow = outInfo[2] - - if (width <= 0 || height <= 0) { - MacNativeBridge.nUnlockFrame(ptr) - return@withContext + if (!waitForPlaybackState(allowInitialFrame = true)) { + delay(100) + continue } + } catch (e: CancellationException) { + break + } - val frameSizeBytes = srcBytesPerRow.toLong() * height.toLong() - var framePublished = false - - try { - withContext(Dispatchers.Default) { - val srcBuf = - MacNativeBridge.nWrapPointer(frameAddress, frameSizeBytes) - ?: return@withContext - - // Allocate/reuse two bitmaps (double-buffering) to avoid writing while the UI draws. - if (skiaBitmapA == null || skiaBitmapWidth != width || skiaBitmapHeight != height) { - skiaBitmapA?.close() - skiaBitmapB?.close() - - val imageInfo = ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE) - skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) } - skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) } - skiaBitmapWidth = width - skiaBitmapHeight = height - nextSkiaBitmapA = true - } - - val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!! - nextSkiaBitmapA = !nextSkiaBitmapA - - val pixmap = targetBitmap.peekPixels() ?: return@withContext - val pixelsAddr = pixmap.addr - if (pixelsAddr == 0L) return@withContext + if (waitIfResizing()) continue - // Single copy: CVPixelBuffer → Skia bitmap pixels (no intermediate buffer) - srcBuf.rewind() - val dstRowBytes = pixmap.rowBytes - val dstSizeBytes = dstRowBytes.toLong() * height.toLong() - val destBuf = - MacNativeBridge.nWrapPointer(pixelsAddr, dstSizeBytes) - ?: return@withContext - copyBgraFrame(srcBuf, destBuf, width, height, srcBytesPerRow, dstRowBytes) + // Short-circuit while a seek is in progress — avoids contending on videoReaderMutex. + if (isSeeking.get()) { + delay(5) + continue + } - _currentFrameState.value = targetBitmap.asComposeImageBitmap() - framePublished = true - } - } finally { - MacNativeBridge.nUnlockFrame(ptr) + val produced = try { + videoReaderMutex.withLock { + processOneFrame(ptr) } + } catch (e: CancellationException) { + break + } catch (e: Exception) { + if (scope.isActive && _hasMedia && !isDisposing.get()) { + setError("Error while reading a frame: ${e.message}") + } + delay(100) + null + } - if (framePublished) { - lastFrameUpdateTime = System.currentTimeMillis() - - // Update loading state if needed on the main thread - if (isLoading && !seekInProgress) { - withContext(Dispatchers.Main) { - isLoading = false - } - } + when (produced) { + ProduceOutcome.NotReady -> { + // AVFoundation delivers frames asynchronously: the first read after a paused + // open can find no buffer yet. Don't spend the one-shot initial-frame allowance + // on an empty buffer — restore it so the next pass tries again and the paused + // thumbnail still appears once the buffer is ready. + if (!_isPlaying && userPaused) initialFrameRead.set(false) + delay(frameIntervalMs) } - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "updateFrameAsync() - Exception: ${e.message}" } + ProduceOutcome.SkipIteration -> delay(frameIntervalMs) + is ProduceOutcome.Frame -> { + frameChannel.trySend(FrameData(produced.bitmap, produced.timestamp)) + delay(frameIntervalMs) + } + null -> { /* exception already handled */ } } } } + private sealed interface ProduceOutcome { + data object NotReady : ProduceOutcome + data object SkipIteration : ProduceOutcome + data class Frame(val bitmap: Bitmap, val timestamp: Double) : ProduceOutcome + } + /** - * Updates the playback position, slider, and audio levels on a background - * thread. + * Locks the latest CVPixelBuffer, copies it to the next Skia bitmap, and returns the outcome. + * Must be called under [videoReaderMutex]. Always unlocks the native frame on exit. */ - private suspend fun updatePositionAsync() { - if (!hasMedia || userDragging) return + private fun processOneFrame(ptr: Long): ProduceOutcome { + // outInfo = [width, height, bytesPerRow] + val outInfo = IntArray(3) + val frameAddress = MacNativeBridge.nLockFrame(ptr, outInfo) + if (frameAddress == 0L) return ProduceOutcome.NotReady try { - val duration = getDurationSafely() - if (duration <= 0) return - - val current = getPositionSafely() - - // Update time text display on the main thread - withContext(Dispatchers.Main) { - _positionText.value = formatTime(current) - _durationText.value = formatTime(duration) + val width = outInfo[0] + val height = outInfo[1] + val srcBytesPerRow = outInfo[2] + if (width <= 0 || height <= 0) return ProduceOutcome.SkipIteration + + // HLS adaptive bitrate may change the decoded size mid-stream. + if (width != videoWidth || height != videoHeight) { + videoWidth = width + videoHeight = height } - // Handle seek in progress - if (seekInProgress && targetSeekTime != null) { - if (abs(current - targetSeekTime!!) < 0.3) { - seekInProgress = false - targetSeekTime = null - withContext(Dispatchers.Main) { - isLoading = false + val srcBuf = + MacNativeBridge.nWrapPointer(frameAddress, srcBytesPerRow.toLong() * height.toLong()) + ?: return ProduceOutcome.SkipIteration + + srcBuf.rewind() + val newHash = calculateFrameHash(srcBuf, width, height, srcBytesPerRow) + if (newHash == lastFrameHash) return ProduceOutcome.SkipIteration + lastFrameHash = newHash + + if (skiaBitmaps[0] == null || skiaBitmapWidth != width || skiaBitmapHeight != height) { + bitmapLock.write { + // Defer-close previous bitmaps instead of freeing memory Compose may still draw. + for (i in skiaBitmaps.indices) { + skiaBitmaps[i]?.let { + pendingCloseBitmaps.addLast(PendingCloseBitmap(it, pendingCloseGraceFrames)) + } + skiaBitmaps[i] = null } - macLogger.d { "Seek completed, resetting loading state" } - } - } else { - // Update slider position, batched with other UI updates to reduce main thread calls - val newSliderPos = if (duration > 0) (current / duration * 1000).toFloat().coerceIn(0f, 1000f) else 0f - withContext(Dispatchers.Main) { - sliderPos = newSliderPos + val imageInfo = ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE) + for (i in skiaBitmaps.indices) { + skiaBitmaps[i] = Bitmap().apply { allocPixels(imageInfo) } + } + skiaBitmapWidth = width + skiaBitmapHeight = height + nextBitmapIndex = 0 + lastSentBitmap = null } } - // Check for looping - checkLoopingAsync() - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error in updatePositionAsync: ${e.message}" } + drainPendingCloseBitmaps() + + // Pick a write target that is neither displayed nor the last frame sent. Three + // buffers minus at most two exclusions always leaves a free one. + val displayedBitmap = bitmapLock.read { _currentFrame } + var candidateIndex = nextBitmapIndex + var attempts = 0 + while (attempts < skiaBitmaps.size - 1 && + (skiaBitmaps[candidateIndex] === displayedBitmap || skiaBitmaps[candidateIndex] === lastSentBitmap) + ) { + candidateIndex = (candidateIndex + 1) % skiaBitmaps.size + attempts++ + } + val targetBitmap = skiaBitmaps[candidateIndex]!! + nextBitmapIndex = (candidateIndex + 1) % skiaBitmaps.size + + val pixmap = targetBitmap.peekPixels() ?: return ProduceOutcome.SkipIteration + val pixelsAddr = pixmap.addr + if (pixelsAddr == 0L) return ProduceOutcome.SkipIteration + + val dstRowBytes = pixmap.rowBytes + val dstBuf = + MacNativeBridge.nWrapPointer(pixelsAddr, dstRowBytes.toLong() * height.toLong()) + ?: return ProduceOutcome.SkipIteration + + srcBuf.rewind() + copyBgraFrame(srcBuf, dstBuf, width, height, srcBytesPerRow, dstRowBytes) + + // Keep the display aspect ratio aligned with the frame we actually draw. + syncAspectRatioToFrame(width, height) + + val timestamp = MacNativeBridge.nGetCurrentTime(ptr) + lastSentBitmap = targetBitmap + return ProduceOutcome.Frame(targetBitmap, timestamp) + } finally { + MacNativeBridge.nUnlockFrame(ptr) } } - /** Checks if playback has ended and triggers loop or stop accordingly. */ - private suspend fun checkLoopingAsync() { - val ptr = playerPtr - if (ptr == 0L) return + private suspend fun consumeFrames() { + var frameReceived = false + var loadingTimeout = 0 - // Trust AVPlayerItemDidPlayToEndTime: it fires reliably on macOS for both - // file and HLS playback. A position-based fallback (current >= duration - x) - // is dangerous because it stops playback x seconds early — the slider - // freezes at (duration - x) / duration instead of reaching 100%. - if (!MacNativeBridge.nConsumeDidPlayToEnd(ptr)) return - - if (loop) { - macLogger.d { "checkLoopingAsync() - Loop enabled, restarting video" } - seekToAsync(0f) - onRestart?.invoke() - } else { - macLogger.d { "checkLoopingAsync() - Video completed, updating state" } - withContext(Dispatchers.Main) { - isPlaying = false + while (scope.isActive && _hasMedia && !isDisposing.get()) { + if (waitIfResizing()) continue + + try { + val frameData = + frameChannel.tryReceive().getOrNull() ?: run { + if (isLoading && !frameReceived) { + loadingTimeout++ + if (loadingTimeout > 200) { + macLogger.w { "No frames received for 3 seconds, forcing isLoading to false" } + isLoading = false + loadingTimeout = 0 + } + } + delay(16) + return@run null + } ?: continue + + loadingTimeout = 0 + frameReceived = true + + bitmapLock.write { + _currentFrame = frameData.bitmap + currentFrameState.value = frameData.bitmap.asComposeImageBitmap() + } + + // Don't clobber the timeline while the user is dragging or a seek is in flight: a + // frame received here can predate the seek, and seekFinished() reads sliderPos + // (backed by _progress) to decide where to seek. + if (!_userDragging && !seekInFlight.get()) { + _currentTime = frameData.timestamp + _progress = + if (_duration > 0.0) { + (_currentTime / _duration).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + } + isLoading = false + + delay(1) + } catch (e: CancellationException) { + break + } catch (e: Exception) { + if (scope.isActive && _hasMedia && !isDisposing.get()) { + setError("Error while processing a frame: ${e.message}") + } + delay(100) } - pauseInBackground() - onPlaybackEnded?.invoke() } } - override fun play() { - macLogger.d { "play() - Starting playback" } - ioScope.launch { - if (!hasMedia && lastUri != null) { - // Reload the media using the saved URI - openUri(lastUri!!) - // The openUri method will start reading if the opening is successful - } else if (hasMedia) { - // If the media is already loaded, start playing in the background - playInBackground() - } else { - withContext(Dispatchers.Main) { - isPlaying = false - isLoading = false + private fun drainPendingCloseBitmaps() { + if (pendingCloseBitmaps.isEmpty()) return + bitmapLock.write { + val iterator = pendingCloseBitmaps.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + entry.framesLeft -= 1 + // The grace counter ticks per *produced* frame; the consumer may not have swapped + // the displayed frame yet. Never close the bitmap Compose is still bound to — + // keep deferring it until a newer frame replaces it (race-free: _currentFrame is + // only written under bitmapLock, which we hold here). + if (entry.framesLeft <= 0 && entry.bitmap !== _currentFrame) { + try { + entry.bitmap.close() + } catch (_: Throwable) { + // Ignore: bitmap may already be released by the Skia cleaner. + } + iterator.remove() } } } } - /** Plays video on a background thread. */ - private suspend fun playInBackground() { - val ptr = playerPtr - if (ptr == 0L) return + // --------------------------------------------------------------------------------------------- + // Playback controls + // --------------------------------------------------------------------------------------------- - try { - MacNativeBridge.nPlay(ptr) + override fun play() { + if (isDisposing.get()) return - withContext(Dispatchers.Main) { - isPlaying = true + if (readyForPlayback()) { + executeMediaOperation(operation = "play") { + resumePlayback() } - - startFrameUpdates() - startBufferingCheck() - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error in playInBackground: ${e.message}" } - handleError(e) + return } - } - override fun pause() { - macLogger.d { "pause() - Pausing playback" } - ioScope.launch { - pauseInBackground() + // Slow path: wait for any in-progress openUri to complete, then resume. + scope.launch { + try { + withTimeout(10_000) { initReady.await() } + withTimeout(10_000) { + snapshotFlow { _hasMedia }.filter { it }.first() + } + } catch (_: Exception) { + // Only kick off a reload if there's genuinely no media AND no open already in + // flight, otherwise a slow open completing near the timeout triggers a redundant + // second open (full reload/flicker of the just-loaded video). This catch also sees + // the CancellationException from dispose() cancelling the scope — never reload then. + if (!_hasMedia && !isOpening.get() && !isDisposing.get()) { + lastUri?.takeIf { it.isNotEmpty() }?.let { uri -> + openUriInternal(uri, InitialPlayerState.PLAY) + } + } + return@launch + } + + mediaOperationMutex.withLock { + if (!isDisposing.get()) resumePlayback() + } } } - /** Pauses video on a background thread. */ - private suspend fun pauseInBackground() { - val ptr = playerPtr - if (ptr == 0L) return + /** Resumes playback — must be called under [mediaOperationMutex]. */ + private fun resumePlayback() { + userPaused = false + initialFrameRead.set(false) - try { - MacNativeBridge.nPause(ptr) + if (!_isPlaying) { + setPlaybackState(true, "Error while starting playback") + } - withContext(Dispatchers.Main) { - isPlaying = false - isLoading = false - } + if (_hasMedia && (videoJob == null || videoJob?.isActive == false)) { + videoJob = startVideoPipeline() + } + } - updateFrameAsync() - stopFrameUpdates() - stopBufferingCheck() - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error in pauseInBackground: ${e.message}" } + override fun pause() { + if (isDisposing.get()) return + + executeMediaOperation( + operation = "pause", + precondition = _isPlaying, + ) { + userPaused = true + // Read a fresh frame to display while paused. + initialFrameRead.set(false) + setPlaybackState(false, "Error while pausing playback") } } override fun stop() { - macLogger.d { "stop() - Stopping playback" } - ioScope.launch { - pauseInBackground() - if (hasMedia) { - seekToAsync(0f) - } - withContext(Dispatchers.Main) { - hasMedia = false - isLoading = false - resetState() + if (isDisposing.get()) return + + executeMediaOperation(operation = "stop") { + setPlaybackState(false, "Error while stopping playback") + playerPtr.takeIf { it != 0L }?.let { ptr -> + // videoReaderMutex serializes nSeekTo against a producer mid-frame-read; lock + // order (mediaOperationMutex → videoReaderMutex) matches performSeek(). + videoReaderMutex.withLock { + MacNativeBridge.nSeekTo(ptr, 0.0) + } } + delay(50) + videoJob?.cancelAndJoin() + releaseAllResources() + _hasMedia = false + _progress = 0f + _currentTime = 0.0 + _duration = 0.0 + isLoading = false + _error = null + userPaused = false + initialFrameRead.set(false) } } override fun seekTo(value: Float) { - macLogger.d { "seekTo() - Seeking with slider value: $value" } - ioScope.launch { - // Throttle rapid seek operations - delay(10) // Small delay to coalesce rapid seek events - seekToAsync(value) - } + if (isDisposing.get()) return + if (_duration <= 0.0) return // Live stream — seeking not supported + + val clamped = value.coerceIn(0f, 1000f) + val targetSeconds = _duration * (clamped / 1000.0) + + // Latch the newest target; whoever is running the seek loop will see it. + pendingSeekTarget.set((targetSeconds * 1_000_000.0).toLong()) + + // Optimistic UI so the slider tracks the drag smoothly while the native seek settles. + _progress = (clamped / 1000f).coerceIn(0f, 1f) + _currentTime = _duration * _progress + + scheduleSeek() } - /** Seeks to a position on a background thread. */ - private suspend fun seekToAsync(value: Float) { - withContext(Dispatchers.Main) { - isLoading = true - } + /** + * Launches the seek loop if no other loop is currently draining the target. Rapid [seekTo] + * calls are coalesced — only the latest target is actually processed. + */ + private fun scheduleSeek() { + if (!seekInFlight.compareAndSet(false, true)) return - try { - val duration = getDurationSafely() - if (duration <= 0) { - withContext(Dispatchers.Main) { - isLoading = false + scope.launch { + try { + while (true) { + val target = pendingSeekTarget.getAndSet(Long.MIN_VALUE) + if (target == Long.MIN_VALUE) break + performSeek(target / 1_000_000.0) } - return + } finally { + seekInFlight.set(false) + if (pendingSeekTarget.get() != Long.MIN_VALUE) scheduleSeek() } + } + } - val seekTime = ((value / 1000f) * duration.toFloat()).coerceIn(0f, duration.toFloat()) - - withContext(Dispatchers.Main) { - seekInProgress = true - targetSeekTime = seekTime.toDouble() - sliderPos = value - } + /** + * Executes a single native seek. Keeps the producer/consumer alive and serializes native reader + * access with [videoReaderMutex] + [isSeeking]. + */ + private suspend fun performSeek(targetSeconds: Double) { + val loadingTrigger = scope.launch { + delay(200) + if (!isDisposing.get()) isLoading = true + } - lastFrameUpdateTime = System.currentTimeMillis() + try { + mediaOperationMutex.withLock { + if (isDisposing.get()) return@withLock + val ptr = playerPtr + if (ptr == 0L || !_hasMedia) return@withLock - val ptr = playerPtr - if (ptr == 0L) return - MacNativeBridge.nSeekTo(ptr, seekTime.toDouble()) - - if (isPlaying) { - MacNativeBridge.nPlay(ptr) - // Reduce delay to update frame faster for local videos - delay(10) - updateFrameAsync() - // Reduced timeout delay from 2000ms to 300ms - ioScope.launch { - delay(300) - if (seekInProgress) { - macLogger.d { "seekToAsync() - Forcing end of seek after timeout" } - seekInProgress = false - targetSeekTime = null - withContext(Dispatchers.Main) { - isLoading = false - } + isSeeking.set(true) + try { + videoReaderMutex.withLock { + initialFrameRead.set(false) + lastFrameHash = Int.MIN_VALUE + clearFrameChannel() + + MacNativeBridge.nSeekTo(ptr, targetSeconds) + // The native seek does not clear didPlayToEnd. An end event that fired + // before (or while) this seek ran refers to the pre-seek position; drain + // it so the producer doesn't later "end" playback at the seek target. + MacNativeBridge.nConsumeDidPlayToEnd(ptr) + // Keep showing frames while playing; a paused seek captures one natively. + if (_isPlaying) MacNativeBridge.nPlay(ptr) + + val pos = MacNativeBridge.nGetCurrentTime(ptr) + _currentTime = pos + _progress = + if (_duration > 0.0) (pos / _duration).toFloat().coerceIn(0f, 1f) else 0f } + } finally { + isSeeking.set(false) + } + + if (!isDisposing.get() && (videoJob == null || videoJob?.isActive == false)) { + videoJob = startVideoPipeline() } } + } finally { + loadingTrigger.cancel() + isLoading = false + } + } + + /** + * Sets the native playback state (play / pause). + * + * @return true if the operation succeeded. + */ + private fun setPlaybackState( + playing: Boolean, + errorMessage: String, + ): Boolean { + val ptr = playerPtr + if (ptr == 0L) { + setError("$errorMessage: No player instance") + return false + } + return try { + if (playing) MacNativeBridge.nPlay(ptr) else MacNativeBridge.nPause(ptr) + _isPlaying = playing + if (_error != null) _error = null + true } catch (e: Exception) { if (e is CancellationException) throw e - macLogger.e { "Error in seekToAsync: ${e.message}" } - withContext(Dispatchers.Main) { - isLoading = false - seekInProgress = false - targetSeekTime = null + setError("$errorMessage: ${e.message}") + false + } + } + + /** + * Waits for playback to become active, allowing one frame to be read while paused (thumbnail). + * + * @return true if the producer should process frames, false if it should keep waiting. + */ + private suspend fun waitForPlaybackState(allowInitialFrame: Boolean = false): Boolean { + if (_isPlaying) return true + if (userPaused && allowInitialFrame && !initialFrameRead.getAndSet(true)) return true + if (isLoading) isLoading = false + + while (scope.isActive && _hasMedia && !isDisposing.get()) { + if (_isPlaying) return true + if (userPaused && allowInitialFrame && !initialFrameRead.getAndSet(true)) return true + try { + delay(40) + } catch (e: CancellationException) { + throw e } } + return false } - override fun dispose() { - macLogger.d { "dispose() - Releasing resources" } - // Cancel all background tasks first - stopFrameUpdates() - stopBufferingCheck() - uiUpdateJob?.cancel() - playerScope.cancel() - - // Clear the pointer atomically so no background task can use it - val ptrToDispose = playerPtrAtomic.getAndSet(0L) + private var resizeWaitCount = 0 - // Release bitmaps on the frame dispatcher (rendering accesses them there) - // then dispose the native player — all on a background thread to avoid - // blocking the main/UI thread. - Thread { + private suspend fun waitIfResizing(): Boolean { + if (isResizing.get()) { + resizeWaitCount++ + if (resizeWaitCount > 200) { + isResizing.set(false) + resizeWaitCount = 0 + return false + } try { - // Close bitmaps (not thread-safe with rendering, but frame updates - // are already cancelled above and playerPtr is zeroed) - skiaBitmapA?.close() - skiaBitmapB?.close() - skiaBitmapA = null - skiaBitmapB = null - skiaBitmapWidth = 0 - skiaBitmapHeight = 0 - nextSkiaBitmapA = true - } catch (e: Exception) { - macLogger.e { "Error releasing bitmaps: ${e.message}" } + yield() + delay(8) + } catch (e: CancellationException) { + throw e } + return true + } + resizeWaitCount = 0 + return false + } + + private fun readyForPlayback(): Boolean = + initReady.isCompleted && playerPtr != 0L && _hasMedia && !isDisposing.get() - if (ptrToDispose != 0L) { - macLogger.d { "dispose() - Disposing native player" } + private fun executeMediaOperation( + operation: String, + precondition: Boolean = true, + block: suspend () -> Unit, + ) { + if (!precondition || isDisposing.get()) return + + scope.launch { + mediaOperationMutex.withLock { try { - MacNativeBridge.nDisposePlayer(ptrToDispose) + if (!isDisposing.get()) block() } catch (e: Exception) { if (e is CancellationException) throw e - macLogger.e { "Error disposing player: ${e.message}" } + setError("Error during $operation: ${e.message}") } } - }.start() - - ioScope.cancel() - } - - /** Resets the player's state. */ - 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 } - /** - * Sets an error in a consistent way, ensuring it's always set on the main thread. - * For synchronous calls, this will block until the error is set. - */ - private fun setPlayerError(error: VideoPlayerError) { - macLogger.e { "setPlayerError() - Setting error: $error" } + // --------------------------------------------------------------------------------------------- + // Resize / output scaling + // --------------------------------------------------------------------------------------------- - // For properties that need to be updated on the main thread, - // use runBlocking to ensure the update happens immediately - runBlocking { - withContext(Dispatchers.Main) { - isLoading = false - this@MacVideoPlayerState.error = error - } - } - } + fun onResized( + width: Int = 0, + height: Int = 0, + ) { + if (isDisposing.get()) return + if (width <= 0 || height <= 0) return + if (width == surfaceWidth && height == surfaceHeight) return - /** Handles errors by updating the state and logging the error. */ - private suspend fun handleError(e: Exception) { - macLogger.e { "handleError() - Player error: ${e.message}" } + surfaceWidth = width + surfaceHeight = height - // Since this is called from a suspend function, we can use withContext directly - withContext(Dispatchers.Main) { - isLoading = false - error = VideoPlayerError.SourceError("Error: ${e.message}") - } + isResizing.set(true) + resizeJob?.cancel() + resizeJob = + scope.launch { + delay(120) + try { + applyOutputScaling() + } finally { + isResizing.set(false) + } + } } - /** Retrieves the current playback time from the native player. */ - private suspend fun getPositionSafely(): Double { + private suspend fun applyOutputScaling() { + if (isDisposing.get() || !_hasMedia) return + val sw = surfaceWidth + val sh = surfaceHeight + if (sw <= 0 || sh <= 0) return val ptr = playerPtr - if (ptr == 0L) return 0.0 - return try { - MacNativeBridge.nGetCurrentTime(ptr) - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error getting position: ${e.message}" } - 0.0 - } - } + if (ptr == 0L) return - /** Retrieves the total duration of the video from the native player. */ - private suspend fun getDurationSafely(): Double { - val ptr = playerPtr - if (ptr == 0L) return 0.0 - return try { - MacNativeBridge.nGetVideoDuration(ptr) - } catch (e: Exception) { - if (e is CancellationException) throw e - macLogger.e { "Error getting duration: ${e.message}" } - 0.0 + mediaOperationMutex.withLock { + // Hold videoReaderMutex too: nSetOutputSize reconfigures the native video output and + // must not run while processOneFrame has a pixel buffer locked. This also makes the + // lastFrameHash reset race-free against processOneFrame's read-modify-write. + // Lock order (mediaOperationMutex → videoReaderMutex) matches performSeek(). + videoReaderMutex.withLock { + MacNativeBridge.nSetOutputSize(ptr, sw, sh) + val w = MacNativeBridge.nGetFrameWidth(ptr) + val h = MacNativeBridge.nGetFrameHeight(ptr) + if (w > 0 && h > 0) { + videoWidth = w + videoHeight = h + // Force reallocation/republish at the new size. + lastFrameHash = Int.MIN_VALUE + } + } } } - /** - * Applies the current volume setting to the native player. If no player - * is available, the volume is simply stored in _volumeState and will be - * applied when the player is initialized. - */ - private suspend fun applyVolume() { - val ptr = playerPtr - if (ptr != 0L) { + // --------------------------------------------------------------------------------------------- + // Volume / speed application (used at init) + // --------------------------------------------------------------------------------------------- + + private fun applyVolume() { + playerPtr.takeIf { it != 0L }?.let { ptr -> try { - MacNativeBridge.nSetVolume(ptr, _volumeState.value) + MacNativeBridge.nSetVolume(ptr, _volume) } catch (e: Exception) { if (e is CancellationException) throw e macLogger.e { "Error applying volume: ${e.message}" } @@ -992,16 +1321,10 @@ class MacVideoPlayerState : VideoPlayerState { } } - /** - * Applies the current playback speed setting to the native player. If no player - * is available, the speed is simply stored in _playbackSpeedState and will be - * applied when the player is initialized. - */ - private suspend fun applyPlaybackSpeed() { - val ptr = playerPtr - if (ptr != 0L) { + private fun applyPlaybackSpeed() { + playerPtr.takeIf { it != 0L }?.let { ptr -> try { - MacNativeBridge.nSetPlaybackSpeed(ptr, _playbackSpeedState.value) + MacNativeBridge.nSetPlaybackSpeed(ptr, _playbackSpeed) } catch (e: Exception) { if (e is CancellationException) throw e macLogger.e { "Error applying playback speed: ${e.message}" } @@ -1009,9 +1332,12 @@ class MacVideoPlayerState : VideoPlayerState { } } - // Subtitle methods (stub implementations) + // --------------------------------------------------------------------------------------------- + // Subtitles / fullscreen + // --------------------------------------------------------------------------------------------- + override fun selectSubtitleTrack(track: SubtitleTrack?) { - ioScope.launch { + scope.launch { withContext(Dispatchers.Main) { currentSubtitleTrack = track subtitlesEnabled = track != null @@ -1020,7 +1346,7 @@ class MacVideoPlayerState : VideoPlayerState { } override fun disableSubtitles() { - ioScope.launch { + scope.launch { withContext(Dispatchers.Main) { subtitlesEnabled = false currentSubtitleTrack = null @@ -1028,70 +1354,84 @@ class MacVideoPlayerState : VideoPlayerState { } } - override fun clearError() { - macLogger.d { "clearError() - Clearing error" } + override fun toggleFullscreen() { + isFullscreen = !isFullscreen + } - // Use runBlocking to ensure the error is cleared immediately - // This is important for tests that expect the error to be cleared synchronously - runBlocking { - withContext(Dispatchers.Main) { - error = null - } - } + // --------------------------------------------------------------------------------------------- + // Cleanup + // --------------------------------------------------------------------------------------------- + + private fun setError(msg: String) { + _error = VideoPlayerError.UnknownError(msg) + isLoading = false + macLogger.e { msg } } - /** - * Toggles the fullscreen state of the video player - */ - override fun toggleFullscreen() { - // Update the state immediately for test synchronization - isFullscreen = !isFullscreen + private fun clearFrameChannel() { + while (frameChannel.tryReceive().isSuccess) { /* drain */ } + } - // Launch any additional background work if needed - ioScope.launch { - // Any additional work related to fullscreen toggle can go here + private fun releaseAllResources() { + videoJob?.cancel() + resizeJob?.cancel() + clearFrameChannel() + + // Do NOT close the triple-buffer bitmaps here: the ImageBitmap exposed via + // currentFrameState shares the same native pixel memory (asComposeImageBitmap is + // zero-copy) and Compose may still be rendering. Nullifying lets the Skia cleaner free + // them once all holders drop their reference. + bitmapLock.write { + _currentFrame = null + currentFrameState.value = null + for (i in skiaBitmaps.indices) skiaBitmaps[i] = null + skiaBitmapWidth = 0 + skiaBitmapHeight = 0 + nextBitmapIndex = 0 + lastSentBitmap = null + lastFrameHash = Int.MIN_VALUE + pendingCloseBitmaps.clear() } + initialFrameRead.set(false) } - /** - * Called when the player surface is resized. Debounces rapid events and - * asks the native layer to decode at the surface size instead of native - * resolution, saving significant memory for high-resolution video. - */ - fun onResized( - width: Int, - height: Int, - ) { - if (width <= 0 || height <= 0) return - if (width == surfaceWidth && height == surfaceHeight) return - - surfaceWidth = width - surfaceHeight = height + override fun dispose() { + if (isDisposing.getAndSet(true)) return - isResizing.set(true) + val jobToJoin = videoJob + videoJob = null + jobToJoin?.cancel() resizeJob?.cancel() - resizeJob = - ioScope.launch { - delay(120) + _isPlaying = false + _hasMedia = false + + releaseAllResources() + + val ptrToDispose = playerPtrAtomic.getAndSet(0L) + lastUri = null + + // Native teardown on a background thread to avoid blocking the UI thread. Before freeing + // the player, wait for the producer to exit AND acquire videoReaderMutex — processOneFrame + // holds that mutex across the whole nLockFrame→…→nUnlockFrame sequence, so holding it once + // guarantees no frame is currently locked and no in-flight nUnlockFrame will touch freed + // memory. The pointer is already zeroed and the job cancelled, so the producer cannot + // re-enter with a valid handle after we release. The timeouts keep shutdown bounded. + if (ptrToDispose != 0L) { + Thread { try { - applyOutputScaling() - } finally { - isResizing.set(false) + runBlocking { + withTimeoutOrNull(500) { jobToJoin?.join() } + withTimeoutOrNull(1000) { videoReaderMutex.withLock { } } + } + } catch (_: Throwable) { /* best effort */ } + try { + MacNativeBridge.nDisposePlayer(ptrToDispose) + } catch (e: Exception) { + macLogger.e { "Error disposing player: ${e.message}" } } - } - } - - /** - * Asks the native layer to produce frames at the display surface size - * instead of full native resolution. Saves significant memory for 4K+ video. - */ - private suspend fun applyOutputScaling() { - val sw = surfaceWidth - val sh = surfaceHeight - if (sw <= 0 || sh <= 0) return - val ptr = playerPtr - if (ptr == 0L) return + }.start() + } - MacNativeBridge.nSetOutputSize(ptr, sw, sh) + scope.cancel() } } diff --git a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift index 54f3bddf..36d1318a 100644 --- a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift +++ b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift @@ -71,6 +71,13 @@ class MacVideoPlayer { private var bufferEmptyObserver: NSKeyValueObservation? private var bufferLikelyToKeepUpObserver: NSKeyValueObservation? private var bufferFullObserver: NSKeyValueObservation? + private var presentationSizeObserver: NSKeyValueObservation? + + // Display aspect ratio (width / height) derived from AVPlayerItem.presentationSize. + // Cached here and updated from the KVO callback so getDisplayAspectRatio() can be called + // from the frame-decoding thread without touching the live AVPlayerItem off the main thread. + private let aspectLock = NSLock() + private var cachedDisplayAspectRatio: Double = 0.0 // End-of-playback flag (set by AVPlayerItemDidPlayToEndTime, consumed once by the Kotlin side) private var didPlayToEnd: Bool = false @@ -886,6 +893,13 @@ class MacVideoPlayer { self?.handleTimeControlStatus(player.timeControlStatus) } + // Cache the display aspect ratio whenever presentationSize changes (e.g. HLS variant + // switches). Reading presentationSize here keeps the live AVPlayerItem access on the + // observation thread instead of the frame-decoding thread. .initial seeds the value now. + presentationSizeObserver = item.observe(\.presentationSize, options: [.initial, .new]) { [weak self] item, _ in + self?.updateCachedDisplayAspectRatio(from: item.presentationSize) + } + // Observe end of playback for all media types playbackEndObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, @@ -1166,6 +1180,29 @@ class MacVideoPlayer { /// Returns the height of the video frame in pixels func getFrameHeight() -> Int { return frameHeight } + /// Recomputes the cached display aspect ratio from a presentationSize. Called on the KVO + /// observation thread; the value is read elsewhere from the frame-decoding thread. + private func updateCachedDisplayAspectRatio(from size: CGSize) { + let ratio = (size.width > 0 && size.height > 0) ? Double(size.width) / Double(size.height) : 0.0 + aspectLock.lock() + cachedDisplayAspectRatio = ratio + aspectLock.unlock() + } + + /// Returns the correct display aspect ratio (width / height) of the current video. + /// + /// Derived from `AVPlayerItem.presentationSize`, which AVFoundation computes with the pixel + /// aspect ratio and clean aperture already applied. This is the geometry the video should be + /// drawn at — it can differ from the raw decoded pixel-buffer dimensions for anamorphic / + /// non-square pixel content, which would otherwise be stretched. The value is cached from a + /// KVO observer (see setup) so this is safe to call off the main thread; returns 0 when not + /// yet available so the caller can fall back to the raw frame dimensions. + func getDisplayAspectRatio() -> Double { + aspectLock.lock() + defer { aspectLock.unlock() } + return cachedDisplayAspectRatio + } + /// Scales the output to fit within (width, height) while preserving the native aspect ratio. /// Never upscales beyond the native resolution. Recreates the pixel buffer output at the new size. /// Returns true if dimensions actually changed. @@ -1310,6 +1347,10 @@ class MacVideoPlayer { bufferEmptyObserver?.invalidate() bufferLikelyToKeepUpObserver?.invalidate() bufferFullObserver?.invalidate() + presentationSizeObserver?.invalidate() + presentationSizeObserver = nil + // Drop the cached aspect ratio so a reopened player can't briefly show the old video's ratio. + updateCachedDisplayAspectRatio(from: .zero) if let observer = playbackEndObserver { NotificationCenter.default.removeObserver(observer) @@ -1420,6 +1461,13 @@ public func getFrameHeight(_ context: UnsafeMutableRawPointer?) -> Int32 { return Int32(player.getFrameHeight()) } +@_cdecl("getDisplayAspectRatio") +public func getDisplayAspectRatio(_ context: UnsafeMutableRawPointer?) -> Double { + guard let context = context else { return 0.0 } + let player = Unmanaged.fromOpaque(context).takeUnretainedValue() + return player.getDisplayAspectRatio() +} + @_cdecl("setOutputSize") public func setOutputSize(_ context: UnsafeMutableRawPointer?, _ width: Int32, _ height: Int32) -> Int32 { guard let context = context else { return 0 } diff --git a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c index 5205ab69..cd8b4784 100644 --- a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c +++ b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c @@ -19,6 +19,7 @@ extern void* lockLatestFrame(void* ctx, int32_t* outInfo); extern void unlockLatestFrame(void* ctx); extern int32_t getFrameWidth(void* ctx); extern int32_t getFrameHeight(void* ctx); +extern double getDisplayAspectRatio(void* ctx); extern int32_t setOutputSize(void* ctx, int32_t width, int32_t height); extern float getVideoFrameRate(void* ctx); extern float getScreenRefreshRate(void* ctx); @@ -106,6 +107,10 @@ static jint JNICALL jni_GetFrameHeight(JNIEnv* env, jclass cls, jlong handle) { return handle ? (jint)getFrameHeight(toCtx(handle)) : 0; } +static jdouble JNICALL jni_GetDisplayAspectRatio(JNIEnv* env, jclass cls, jlong handle) { + return handle ? (jdouble)getDisplayAspectRatio(toCtx(handle)) : 0.0; +} + static jint JNICALL jni_SetOutputSize(JNIEnv* env, jclass cls, jlong handle, jint width, jint height) { return handle ? (jint)setOutputSize(toCtx(handle), (int32_t)width, (int32_t)height) : 0; } @@ -196,6 +201,7 @@ static const JNINativeMethod g_methods[] = { { "nWrapPointer", "(JJ)Ljava/nio/ByteBuffer;", (void*)jni_WrapPointer }, { "nGetFrameWidth", "(J)I", (void*)jni_GetFrameWidth }, { "nGetFrameHeight", "(J)I", (void*)jni_GetFrameHeight }, + { "nGetDisplayAspectRatio", "(J)D", (void*)jni_GetDisplayAspectRatio }, { "nSetOutputSize", "(JII)I", (void*)jni_SetOutputSize }, { "nGetVideoFrameRate", "(J)F", (void*)jni_GetVideoFrameRate }, { "nGetScreenRefreshRate", "(J)F", (void*)jni_GetScreenRefreshRate }, diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt index a4795e1c..0ef47c4b 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt @@ -9,27 +9,76 @@ import kotlin.test.assertNotEquals class MacFrameUtilsTest { @Test fun calculateFrameHash_returnsZeroWhenEmpty() { - assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), 0)) - assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), -1)) + assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), 0, 0, 0)) + assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), -1, 1, 0)) + assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), 1, -1, 0)) } @Test fun calculateFrameHash_changesWhenSampledPixelChanges() { - val pixelCount = 1000 - val buf = ByteBuffer.allocate(pixelCount * 4) - for (i in 0 until pixelCount) { + val width = 100 + val height = 10 + val rowBytes = width * 4 + val buf = ByteBuffer.allocate(rowBytes * height) + for (i in 0 until width * height) { buf.putInt(i * 4, i) } - val hash1 = calculateFrameHash(buf, pixelCount) + val hash1 = calculateFrameHash(buf, width, height, rowBytes) - // With pixelCount=1000, step=5 => index 5 is sampled. + // With pixelCount=1000, step=5 => linear index 5 (x=5, y=0) is sampled. buf.putInt(5 * 4, 123456) - val hash2 = calculateFrameHash(buf, pixelCount) + val hash2 = calculateFrameHash(buf, width, height, rowBytes) assertNotEquals(hash1, hash2) } + @Test + fun calculateFrameHash_ignoresRowPaddingBytes() { + val width = 100 + val height = 10 + val rowBytes = width * 4 + 16 // padded stride + + fun frame(padding: Byte): ByteBuffer { + val buf = ByteBuffer.allocate(rowBytes * height) + for (i in 0 until buf.capacity()) buf.put(i, padding) + for (y in 0 until height) { + for (x in 0 until width) { + buf.putInt(y * rowBytes + x * 4, y * width + x) + } + } + return buf + } + + // Same pixels, different garbage in the padding — hashes must match. + assertEquals( + calculateFrameHash(frame(0x00), width, height, rowBytes), + calculateFrameHash(frame(0x55), width, height, rowBytes), + ) + } + + @Test + fun calculateFrameHash_doesNotSampleSingleColumnWhenStepIsMultipleOfWidth() { + // pixelCount=4000 => raw step 20, a multiple of width: naive sampling would only + // ever read column x=0 and miss every other change in the frame. + val width = 10 + val height = 400 + val rowBytes = width * 4 + + val blank = ByteBuffer.allocate(rowBytes * height) + val changedOutsideFirstColumn = ByteBuffer.allocate(rowBytes * height) + for (y in 0 until height) { + for (x in 1 until width) { + changedOutsideFirstColumn.putInt(y * rowBytes + x * 4, 0xCAFE) + } + } + + assertNotEquals( + calculateFrameHash(blank, width, height, rowBytes), + calculateFrameHash(changedOutsideFirstColumn, width, height, rowBytes), + ) + } + @Test fun copyBgraFrame_copiesContiguousRows() { val width = 2