Skip to content

Commit 413d330

Browse files
committed
fix(windows): defer Skia bitmap release to avoid race conditions during resolution changes
- Add deferred-close queue to safely manage bitmap lifecycle during adaptive bitrate (HLS) resolution changes. - Ensure old bitmaps are cleared only after a safe number of frames to prevent Skia use-after-free crashes. - Implement `drainPendingCloseBitmaps` to manage and clean pending bitmaps effectively. - Extend JVM buildArgs to enable custom URL protocols (`--enable-url-protocols=http,https`).
1 parent 0ff7e83 commit 413d330

2 files changed

Lines changed: 40 additions & 10 deletions

File tree

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,14 @@ class WindowsVideoPlayerState : VideoPlayerState {
292292
private var skiaBitmapWidth: Int = 0
293293
private var skiaBitmapHeight: Int = 0
294294

295+
// Bitmaps awaiting safe closure. When the video resolution changes mid-stream
296+
// (HLS adaptive bitrate) the old double-buffer bitmaps may still be read by
297+
// Compose on the AWT thread via currentFrameState. We defer close() by a few
298+
// consumed frames so Compose has swapped to the new bitmap first.
299+
private data class PendingCloseBitmap(val bitmap: Bitmap, var framesLeft: Int)
300+
private val pendingCloseBitmaps = ArrayDeque<PendingCloseBitmap>()
301+
private val pendingCloseGraceFrames: Int = 4
302+
295303
// Adaptive frame interval (ms) based on the video's native frame rate.
296304
// Mirrors macOS approach: poll at the video frame rate, not faster.
297305
// This prevents starving the audio thread on the shared SourceReader.
@@ -399,6 +407,9 @@ class WindowsVideoPlayerState : VideoPlayerState {
399407
skiaBitmapHeight = 0
400408
nextSkiaBitmapA = true
401409
lastFrameHash = Int.MIN_VALUE
410+
// Deferred-close queue: drop refs, let Skia cleaner finalize them
411+
// (AWT may still hold the newest one).
412+
pendingCloseBitmaps.clear()
402413
}
403414

404415
// Reset all state
@@ -441,6 +452,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
441452
skiaBitmapHeight = 0
442453
nextSkiaBitmapA = true
443454
lastFrameHash = Int.MIN_VALUE
455+
pendingCloseBitmaps.clear()
444456
}
445457

446458
// Reset initialFrameRead flag to ensure we read an initial frame when reinitialized
@@ -809,15 +821,11 @@ class WindowsVideoPlayerState : VideoPlayerState {
809821

810822
if (skiaBitmapA == null || skiaBitmapWidth != width || skiaBitmapHeight != height) {
811823
bitmapLock.write {
812-
// Do NOT close the previous bitmaps: the most recent one is
813-
// shared (zero-copy) with the ImageBitmap currently held by
814-
// Compose via currentFrameState and may still be drawn on the
815-
// AWT-EventQueue. Closing destroys the underlying Skia peer
816-
// and causes a null-pointer crash in Image.makeFromBitmap.
817-
// Same pattern as releaseAllResources(): drop the reference
818-
// and let the Skia managed cleaner reclaim it once Compose
819-
// releases its hold. Sacrifices a frame's worth of RAM on
820-
// resolution changes — the correct trade-off.
824+
// Queue previous bitmaps for deferred close instead of leaking them
825+
// to the Skia managed cleaner: closing now would race with Compose
826+
// still drawing the last frame on the AWT thread.
827+
skiaBitmapA?.let { pendingCloseBitmaps.addLast(PendingCloseBitmap(it, pendingCloseGraceFrames)) }
828+
skiaBitmapB?.let { pendingCloseBitmaps.addLast(PendingCloseBitmap(it, pendingCloseGraceFrames)) }
821829
val imageInfo = createVideoImageInfo()
822830
skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) }
823831
skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) }
@@ -827,6 +835,8 @@ class WindowsVideoPlayerState : VideoPlayerState {
827835
}
828836
}
829837

838+
drainPendingCloseBitmaps()
839+
830840
val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!!
831841
nextSkiaBitmapA = !nextSkiaBitmapA
832842

@@ -1228,6 +1238,25 @@ class WindowsVideoPlayerState : VideoPlayerState {
12281238
*/
12291239
private fun createVideoImageInfo() = ImageInfo(videoWidth, videoHeight, ColorType.BGRA_8888, ColorAlphaType.OPAQUE)
12301240

1241+
private fun drainPendingCloseBitmaps() {
1242+
if (pendingCloseBitmaps.isEmpty()) return
1243+
bitmapLock.write {
1244+
val iterator = pendingCloseBitmaps.iterator()
1245+
while (iterator.hasNext()) {
1246+
val entry = iterator.next()
1247+
entry.framesLeft -= 1
1248+
if (entry.framesLeft <= 0) {
1249+
try {
1250+
entry.bitmap.close()
1251+
} catch (_: Throwable) {
1252+
// Ignore: bitmap may already be released by Skia cleaner.
1253+
}
1254+
iterator.remove()
1255+
}
1256+
}
1257+
}
1258+
}
1259+
12311260
/**
12321261
* Sets the playback state (playing or paused)
12331262
*

sample/composeApp/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ nucleus.application {
132132
jvmVendor = JvmVendorSpec.BELLSOFT
133133
buildArgs.addAll(
134134
"-H:+AddAllCharsets",
135-
"-Djava.awt.headless=false"
135+
"-Djava.awt.headless=false",
136+
"--enable-url-protocols=http,https"
136137
)
137138
}
138139
}

0 commit comments

Comments
 (0)