Skip to content

Commit 196cdbf

Browse files
committed
Fix macOS playback freezes, tearing, and stale-state bugs in video player
The frame-hash sampling step could land on a single x column (step multiple of width), freezing dedup on static edges; the triple-buffer round-robin could write into the displayed or in-flight bitmap and tear; and several stale-state races caused wrong behavior across media loads: - Bump the hash sampling step when it is a multiple of the frame width - Skip the displayed and last-sent bitmaps when picking a write target, and never close the bitmap Compose is still bound to - Clear pendingSeekTarget when opening new media so an old seek target cannot be applied to the new video - Drain didPlayToEnd after a native seek and ignore it mid-drag to prevent spurious end-of-playback - Keep the frame producer alive after playback ends so resume/seek can relaunch the pipeline - Don't reload media when position observation is cancelled by dispose() - Serialize stop()'s nSeekTo behind videoReaderMutex - Rewrite checkExistsIfLocalFile with java.net.URI to accept all file-URI forms and percent-encoded paths Adds tests for row-padding-aware hashing and the single-column step case.
1 parent 5f81607 commit 196cdbf

3 files changed

Lines changed: 135 additions & 30 deletions

File tree

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtils.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ internal fun calculateFrameHash(
1212

1313
val pixelCount = width * height
1414
var hash = 1
15-
val step = if (pixelCount <= 200) 1 else pixelCount / 200
15+
var step = if (pixelCount <= 200) 1 else pixelCount / 200
16+
// A step that is a multiple of the width would pin every sample to the same x column
17+
// (e.g. 720x400 → step 1440 → x always 0); with a static edge (letterboxing, dark scene
18+
// border) the hash would never change and the dedup would freeze the video.
19+
if (width > 1 && step % width == 0) step++
1620
var i = 0
1721
while (i < pixelCount) {
1822
// Map the linear sample index to its real byte offset, honoring row padding

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ import org.jetbrains.skia.ColorAlphaType
4747
import org.jetbrains.skia.ColorType
4848
import org.jetbrains.skia.ImageInfo
4949
import java.io.File
50+
import java.net.URI
5051
import java.util.concurrent.atomic.AtomicBoolean
5152
import java.util.concurrent.atomic.AtomicLong
5253
import java.util.concurrent.locks.ReentrantReadWriteLock
54+
import kotlin.concurrent.read
5355
import kotlin.concurrent.write
5456

5557
internal val macLogger = TaggedLogger("MacVideoPlayerState")
@@ -263,6 +265,12 @@ class MacVideoPlayerState : VideoPlayerState {
263265
// bound to ImageBitmap and the one Compose just finished.
264266
private val skiaBitmaps = arrayOfNulls<Bitmap>(3)
265267
private var nextBitmapIndex: Int = 0
268+
269+
// Bitmap most recently handed to frameChannel (producer-thread only). Excluded as a write
270+
// target together with the displayed bitmap: with the drop-oldest channel the consumer can
271+
// lag a full slot behind, and blind round-robin would then write into the bitmap currently
272+
// bound to Compose (or sitting undelivered in the channel) and tear on screen.
273+
private var lastSentBitmap: Bitmap? = null
266274
@Volatile
267275
private var lastFrameHash: Int = Int.MIN_VALUE
268276
private var skiaBitmapWidth: Int = 0
@@ -380,6 +388,10 @@ class MacVideoPlayerState : VideoPlayerState {
380388
_hasMedia = false
381389
userPaused = false
382390
initialFrameRead.set(false)
391+
// Discard any seek latched against the previous media; if the in-flight seek
392+
// loop only drained it after the new media finished opening, the new video
393+
// would jump to the old target.
394+
pendingSeekTarget.set(Long.MIN_VALUE)
383395

384396
// Validate local files before handing off to the native layer.
385397
if (!checkExistsIfLocalFile(uri)) {
@@ -526,18 +538,24 @@ class MacVideoPlayerState : VideoPlayerState {
526538
return 0.0
527539
}
528540

529-
// Handles both URIs with a "file:" scheme and simple filenames; network URIs are assumed reachable.
530-
private fun checkExistsIfLocalFile(uri: String): Boolean {
531-
val schemeDelimiter = uri.indexOf("://")
532-
val scheme = if (schemeDelimiter >= 0) uri.substring(0, schemeDelimiter) else ""
533-
return when (scheme) {
534-
"", "file" -> {
535-
val path = if (scheme == "file") uri.removePrefix("file://") else uri
536-
File(path).exists()
541+
// Handles plain paths and every file-URI shape (file:/p, file://p, file:///p — naive
542+
// "file://" prefix-stripping rejected the single-slash form java.net.URI produces);
543+
// URI parsing also undoes percent-encoding so paths with escaped characters are checked
544+
// correctly. Network URIs are assumed reachable.
545+
private fun checkExistsIfLocalFile(uri: String): Boolean =
546+
try {
547+
val parsed = URI(uri)
548+
when {
549+
parsed.scheme == null -> File(uri).exists()
550+
parsed.scheme.equals("file", ignoreCase = true) ->
551+
// path is null for opaque URIs like "file:relative/path" — malformed for us.
552+
parsed.path?.let { File(it).exists() } ?: false
553+
else -> true
537554
}
538-
else -> true
555+
} catch (_: Exception) {
556+
// Not parseable as a URI (e.g. a plain path with spaces) — treat as a local path.
557+
File(uri).exists()
539558
}
540-
}
541559

542560
// ---------------------------------------------------------------------------------------------
543561
// Aspect ratio (the deliberate divergence from Windows — display-AR correct)
@@ -685,10 +703,13 @@ class MacVideoPlayerState : VideoPlayerState {
685703
if (ptr == 0L) break
686704

687705
// End-of-playback: AVFoundation fires AVPlayerItemDidPlayToEndTime, surfaced as a
688-
// one-shot flag. Only consume/act on it while genuinely playing and not mid-seek —
689-
// otherwise a stray flag observed during a seek or paused thumbnail read could be
690-
// swallowed or trigger a spurious end. Mirrors the Windows IsEOF branch.
691-
if (_isPlaying && !isSeeking.get() && !seekInFlight.get() && MacNativeBridge.nConsumeDidPlayToEnd(ptr)) {
706+
// one-shot flag. Only consume/act on it while genuinely playing and not mid-seek or
707+
// mid-drag — otherwise a stray flag observed during a seek or paused thumbnail read
708+
// could be swallowed or trigger a spurious end (a drag always ends in a seek, whose
709+
// performSeek() drains any flag that became stale). Mirrors the Windows IsEOF branch.
710+
if (_isPlaying && !_userDragging && !isSeeking.get() && !seekInFlight.get() &&
711+
MacNativeBridge.nConsumeDidPlayToEnd(ptr)
712+
) {
692713
if (_duration <= 0.0) {
693714
// Live stream — wait and continue.
694715
delay(1000)
@@ -723,7 +744,11 @@ class MacVideoPlayerState : VideoPlayerState {
723744
if (e is CancellationException) throw e
724745
}
725746
onPlaybackEnded?.invoke()
726-
break
747+
// Keep the producer alive: consumeFrames/observePosition still run, so
748+
// videoJob stays active and resumePlayback()/performSeek() would never
749+
// relaunch the pipeline if we exited here. Falling through to
750+
// waitForPlaybackState parks this coroutine cheaply until play() resumes.
751+
continue
727752
}
728753
}
729754

@@ -830,13 +855,25 @@ class MacVideoPlayerState : VideoPlayerState {
830855
skiaBitmapWidth = width
831856
skiaBitmapHeight = height
832857
nextBitmapIndex = 0
858+
lastSentBitmap = null
833859
}
834860
}
835861

836862
drainPendingCloseBitmaps()
837863

838-
val targetBitmap = skiaBitmaps[nextBitmapIndex]!!
839-
nextBitmapIndex = (nextBitmapIndex + 1) % skiaBitmaps.size
864+
// Pick a write target that is neither displayed nor the last frame sent. Three
865+
// buffers minus at most two exclusions always leaves a free one.
866+
val displayedBitmap = bitmapLock.read { _currentFrame }
867+
var candidateIndex = nextBitmapIndex
868+
var attempts = 0
869+
while (attempts < skiaBitmaps.size - 1 &&
870+
(skiaBitmaps[candidateIndex] === displayedBitmap || skiaBitmaps[candidateIndex] === lastSentBitmap)
871+
) {
872+
candidateIndex = (candidateIndex + 1) % skiaBitmaps.size
873+
attempts++
874+
}
875+
val targetBitmap = skiaBitmaps[candidateIndex]!!
876+
nextBitmapIndex = (candidateIndex + 1) % skiaBitmaps.size
840877

841878
val pixmap = targetBitmap.peekPixels() ?: return ProduceOutcome.SkipIteration
842879
val pixelsAddr = pixmap.addr
@@ -854,6 +891,7 @@ class MacVideoPlayerState : VideoPlayerState {
854891
syncAspectRatioToFrame(width, height)
855892

856893
val timestamp = MacNativeBridge.nGetCurrentTime(ptr)
894+
lastSentBitmap = targetBitmap
857895
return ProduceOutcome.Frame(targetBitmap, timestamp)
858896
} finally {
859897
MacNativeBridge.nUnlockFrame(ptr)
@@ -923,7 +961,11 @@ class MacVideoPlayerState : VideoPlayerState {
923961
while (iterator.hasNext()) {
924962
val entry = iterator.next()
925963
entry.framesLeft -= 1
926-
if (entry.framesLeft <= 0) {
964+
// The grace counter ticks per *produced* frame; the consumer may not have swapped
965+
// the displayed frame yet. Never close the bitmap Compose is still bound to —
966+
// keep deferring it until a newer frame replaces it (race-free: _currentFrame is
967+
// only written under bitmapLock, which we hold here).
968+
if (entry.framesLeft <= 0 && entry.bitmap !== _currentFrame) {
927969
try {
928970
entry.bitmap.close()
929971
} catch (_: Throwable) {
@@ -959,8 +1001,9 @@ class MacVideoPlayerState : VideoPlayerState {
9591001
} catch (_: Exception) {
9601002
// Only kick off a reload if there's genuinely no media AND no open already in
9611003
// flight, otherwise a slow open completing near the timeout triggers a redundant
962-
// second open (full reload/flicker of the just-loaded video).
963-
if (!_hasMedia && !isOpening.get()) {
1004+
// second open (full reload/flicker of the just-loaded video). This catch also sees
1005+
// the CancellationException from dispose() cancelling the scope — never reload then.
1006+
if (!_hasMedia && !isOpening.get() && !isDisposing.get()) {
9641007
lastUri?.takeIf { it.isNotEmpty() }?.let { uri ->
9651008
openUriInternal(uri, InitialPlayerState.PLAY)
9661009
}
@@ -1008,7 +1051,11 @@ class MacVideoPlayerState : VideoPlayerState {
10081051
executeMediaOperation(operation = "stop") {
10091052
setPlaybackState(false, "Error while stopping playback")
10101053
playerPtr.takeIf { it != 0L }?.let { ptr ->
1011-
MacNativeBridge.nSeekTo(ptr, 0.0)
1054+
// videoReaderMutex serializes nSeekTo against a producer mid-frame-read; lock
1055+
// order (mediaOperationMutex → videoReaderMutex) matches performSeek().
1056+
videoReaderMutex.withLock {
1057+
MacNativeBridge.nSeekTo(ptr, 0.0)
1058+
}
10121059
}
10131060
delay(50)
10141061
videoJob?.cancelAndJoin()
@@ -1086,6 +1133,10 @@ class MacVideoPlayerState : VideoPlayerState {
10861133
clearFrameChannel()
10871134

10881135
MacNativeBridge.nSeekTo(ptr, targetSeconds)
1136+
// The native seek does not clear didPlayToEnd. An end event that fired
1137+
// before (or while) this seek ran refers to the pre-seek position; drain
1138+
// it so the producer doesn't later "end" playback at the seek target.
1139+
MacNativeBridge.nConsumeDidPlayToEnd(ptr)
10891140
// Keep showing frames while playing; a paused seek captures one natively.
10901141
if (_isPlaying) MacNativeBridge.nPlay(ptr)
10911142

@@ -1337,6 +1388,7 @@ class MacVideoPlayerState : VideoPlayerState {
13371388
skiaBitmapWidth = 0
13381389
skiaBitmapHeight = 0
13391390
nextBitmapIndex = 0
1391+
lastSentBitmap = null
13401392
lastFrameHash = Int.MIN_VALUE
13411393
pendingCloseBitmaps.clear()
13421394
}

mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacFrameUtilsTest.kt

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,76 @@ import kotlin.test.assertNotEquals
99
class MacFrameUtilsTest {
1010
@Test
1111
fun calculateFrameHash_returnsZeroWhenEmpty() {
12-
assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), 0))
13-
assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), -1))
12+
assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), 0, 0, 0))
13+
assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), -1, 1, 0))
14+
assertEquals(0, calculateFrameHash(ByteBuffer.allocate(0), 1, -1, 0))
1415
}
1516

1617
@Test
1718
fun calculateFrameHash_changesWhenSampledPixelChanges() {
18-
val pixelCount = 1000
19-
val buf = ByteBuffer.allocate(pixelCount * 4)
20-
for (i in 0 until pixelCount) {
19+
val width = 100
20+
val height = 10
21+
val rowBytes = width * 4
22+
val buf = ByteBuffer.allocate(rowBytes * height)
23+
for (i in 0 until width * height) {
2124
buf.putInt(i * 4, i)
2225
}
2326

24-
val hash1 = calculateFrameHash(buf, pixelCount)
27+
val hash1 = calculateFrameHash(buf, width, height, rowBytes)
2528

26-
// With pixelCount=1000, step=5 => index 5 is sampled.
29+
// With pixelCount=1000, step=5 => linear index 5 (x=5, y=0) is sampled.
2730
buf.putInt(5 * 4, 123456)
28-
val hash2 = calculateFrameHash(buf, pixelCount)
31+
val hash2 = calculateFrameHash(buf, width, height, rowBytes)
2932

3033
assertNotEquals(hash1, hash2)
3134
}
3235

36+
@Test
37+
fun calculateFrameHash_ignoresRowPaddingBytes() {
38+
val width = 100
39+
val height = 10
40+
val rowBytes = width * 4 + 16 // padded stride
41+
42+
fun frame(padding: Byte): ByteBuffer {
43+
val buf = ByteBuffer.allocate(rowBytes * height)
44+
for (i in 0 until buf.capacity()) buf.put(i, padding)
45+
for (y in 0 until height) {
46+
for (x in 0 until width) {
47+
buf.putInt(y * rowBytes + x * 4, y * width + x)
48+
}
49+
}
50+
return buf
51+
}
52+
53+
// Same pixels, different garbage in the padding — hashes must match.
54+
assertEquals(
55+
calculateFrameHash(frame(0x00), width, height, rowBytes),
56+
calculateFrameHash(frame(0x55), width, height, rowBytes),
57+
)
58+
}
59+
60+
@Test
61+
fun calculateFrameHash_doesNotSampleSingleColumnWhenStepIsMultipleOfWidth() {
62+
// pixelCount=4000 => raw step 20, a multiple of width: naive sampling would only
63+
// ever read column x=0 and miss every other change in the frame.
64+
val width = 10
65+
val height = 400
66+
val rowBytes = width * 4
67+
68+
val blank = ByteBuffer.allocate(rowBytes * height)
69+
val changedOutsideFirstColumn = ByteBuffer.allocate(rowBytes * height)
70+
for (y in 0 until height) {
71+
for (x in 1 until width) {
72+
changedOutsideFirstColumn.putInt(y * rowBytes + x * 4, 0xCAFE)
73+
}
74+
}
75+
76+
assertNotEquals(
77+
calculateFrameHash(blank, width, height, rowBytes),
78+
calculateFrameHash(changedOutsideFirstColumn, width, height, rowBytes),
79+
)
80+
}
81+
3382
@Test
3483
fun copyBgraFrame_copiesContiguousRows() {
3584
val width = 2

0 commit comments

Comments
 (0)