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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ Pods/
*yarn.lock
# Native compiled binaries (built locally or in CI)
/mediaplayer/src/jvmMain/resources/composemediaplayer/native/
/mediaplayer/src/jvmMain/resources/win32-x86-64/
/mediaplayer/src/jvmMain/resources/win32-arm64/

# Native build artifacts
/mediaplayer/src/jvmMain/native/windows/build-x64/
/mediaplayer/src/jvmMain/native/windows/build-arm64/
/mediaplayer/src/jvmMain/native/windows/build-test/
*.log
/sample/composeApp/debug/
NUL
.claude/
2 changes: 1 addition & 1 deletion mediaplayer/ComposeMediaPlayer.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ Pod::Spec.new do |spec|
SCRIPT
}
]
spec.resources = ['build/compose/cocoapods/compose-resources']
spec.resources = ['build\compose\cocoapods\compose-resources']
end
Original file line number Diff line number Diff line change
Expand Up @@ -718,26 +718,32 @@ class LinuxVideoPlayerState : VideoPlayerState {
uiUpdateJob?.cancel()
playerScope.cancel()

// Dispose the native player synchronously to guarantee cleanup before
// ioScope is cancelled — otherwise GStreamer keeps running (audio leak).
// Clear the pointer atomically so no background task can use it
val ptrToDispose = playerPtrAtomic.getAndSet(0L)

skiaBitmapA?.close()
skiaBitmapB?.close()
skiaBitmapA = null
skiaBitmapB = null
skiaBitmapWidth = 0
skiaBitmapHeight = 0
nextSkiaBitmapA = true

if (ptrToDispose != 0L) {
// Native cleanup on a background thread to avoid blocking the UI.
Thread {
try {
LinuxNativeBridge.nDisposePlayer(ptrToDispose)
skiaBitmapA?.close()
skiaBitmapB?.close()
skiaBitmapA = null
skiaBitmapB = null
skiaBitmapWidth = 0
skiaBitmapHeight = 0
nextSkiaBitmapA = true
} catch (e: Exception) {
if (e is CancellationException) throw e
linuxLogger.e { "Error disposing player: ${e.message}" }
linuxLogger.e { "Error releasing bitmaps: ${e.message}" }
}
}

if (ptrToDispose != 0L) {
try {
LinuxNativeBridge.nDisposePlayer(ptrToDispose)
} catch (e: Exception) {
if (e is CancellationException) throw e
linuxLogger.e { "Error disposing player: ${e.message}" }
}
}
}.start()

ioScope.cancel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,32 +861,37 @@ class MacVideoPlayerState : VideoPlayerState {
uiUpdateJob?.cancel()
playerScope.cancel()

// Dispose synchronously to guarantee cleanup before ioScope is cancelled —
// otherwise AVPlayer keeps running (audio leak).
// Use frameDispatcher to safely close bitmaps (rendering accesses them there).
val ptrToDispose = runBlocking(frameDispatcher) {
val ptr = playerPtrAtomic.getAndSet(0L)

skiaBitmapA?.close()
skiaBitmapB?.close()
skiaBitmapA = null
skiaBitmapB = null
skiaBitmapWidth = 0
skiaBitmapHeight = 0
nextSkiaBitmapA = true

ptr
}
// Clear the pointer atomically so no background task can use it
val ptrToDispose = playerPtrAtomic.getAndSet(0L)

if (ptrToDispose != 0L) {
macLogger.d { "dispose() - Disposing native player" }
// 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 {
try {
MacNativeBridge.nDisposePlayer(ptrToDispose)
// 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) {
if (e is CancellationException) throw e
macLogger.e { "Error disposing player: ${e.message}" }
macLogger.e { "Error releasing bitmaps: ${e.message}" }
}
}

if (ptrToDispose != 0L) {
macLogger.d { "dispose() - Disposing native player" }
try {
MacNativeBridge.nDisposePlayer(ptrToDispose)
} catch (e: Exception) {
if (e is CancellationException) throw e
macLogger.e { "Error disposing player: ${e.message}" }
}
}
}.start()

ioScope.cancel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ class WindowsVideoPlayerState : VideoPlayerState {
private var skiaBitmapWidth: Int = 0
private var skiaBitmapHeight: Int = 0

// Adaptive frame interval (ms) based on the video's native frame rate.
// Mirrors macOS approach: poll at the video frame rate, not faster.
// This prevents starving the audio thread on the shared SourceReader.
private var frameIntervalMs: Long = 16L // Default ~60fps, updated after open

// Variable to store the last opened URI
private var lastUri: String? = null

Expand Down Expand Up @@ -292,65 +297,43 @@ class WindowsVideoPlayerState : VideoPlayerState {
return // Already disposing
}

// Cancel the scope immediately to stop all coroutines
scope.cancel()

// Use runBlocking to ensure resources are cleaned up synchronously
runBlocking {
try {
// Cancel all jobs with immediate effect
videoJob?.cancel()
resizeJob?.cancel()

// Wait a bit for coroutines to cancel
delay(50)

mediaOperationMutex.withLock {
// Stop playing if active
_isPlaying = false
val instance = videoPlayerInstance
if (instance != 0L) {
try {
// Stop playback before releasing resources
val hr = player.SetPlaybackState(instance, false, true)
if (hr < 0) {
windowsLogger.e { "Error stopping playback (hr=0x${hr.toString(16)})" }
}
} catch (e: Exception) {
windowsLogger.e { "Exception stopping playback: ${e.message}" }
}

// Close the media
try {
player.CloseMedia(instance)
} catch (e: Exception) {
windowsLogger.e { "Exception closing media: ${e.message}" }
}

// Remove volume setting for this instance
instanceVolumes.remove(instance)
// Stop coroutines first — non-blocking
videoJob?.cancel()
resizeJob?.cancel()
_isPlaying = false
_hasMedia = false

// Destroy the player instance
try {
WindowsNativeBridge.destroyInstance(instance)
} catch (e: Exception) {
windowsLogger.e { "Exception destroying instance: ${e.message}" }
}
// Release Kotlin-side resources immediately (bitmaps, channel)
releaseAllResources()

videoPlayerInstance = 0L
}
// Native cleanup on a background thread so dispose() never blocks the UI.
// scope is about to be cancelled, so use a detached thread.
val instance = videoPlayerInstance
videoPlayerInstance = 0L
lastUri = null

// Clear all resources
clearAllResourcesSync()
if (instance != 0L) {
Thread {
try {
player.SetPlaybackState(instance, false, true)
} catch (e: Exception) {
windowsLogger.e { "Exception stopping playback: ${e.message}" }
}
} catch (e: Exception) {
windowsLogger.e { "Error during dispose: ${e.message}" }
} finally {
// Mark player as uninitialized
_hasMedia = false
lastUri = null
}
try {
player.CloseMedia(instance)
} catch (e: Exception) {
windowsLogger.e { "Exception closing media: ${e.message}" }
}
instanceVolumes.remove(instance)
try {
WindowsNativeBridge.destroyInstance(instance)
} catch (e: Exception) {
windowsLogger.e { "Exception destroying instance: ${e.message}" }
}
}.start()
}

scope.cancel()
}

private fun clearAllResourcesSync() {
Expand Down Expand Up @@ -592,6 +575,16 @@ class WindowsVideoPlayerState : VideoPlayerState {
)
}

// Query the native frame rate to compute an adaptive polling interval
// like macOS does with captureFrameRate.
val rateArr = IntArray(2)
if (player.nGetVideoFrameRate(instance, rateArr) >= 0 && rateArr[0] > 0) {
val fps = rateArr[0].toDouble() / rateArr[1].coerceAtLeast(1).toDouble()
frameIntervalMs = (1000.0 / fps).toLong().coerceIn(8L, 50L)
} else {
frameIntervalMs = 16L // fallback ~60fps
}

// Set _hasMedia to true only if everything succeeded
_hasMedia = true

Expand Down Expand Up @@ -807,6 +800,8 @@ class WindowsVideoPlayerState : VideoPlayerState {
// Send frame to channel
frameChannel.trySend(FrameData(targetBitmap, frameTime))

// Native AcquireNextSample already paces video to the audio
// clock via PreciseSleepHighRes — no additional delay needed.
delay(1)
} catch (e: CancellationException) {
break
Expand Down
Loading