@@ -47,9 +47,11 @@ import org.jetbrains.skia.ColorAlphaType
4747import org.jetbrains.skia.ColorType
4848import org.jetbrains.skia.ImageInfo
4949import java.io.File
50+ import java.net.URI
5051import java.util.concurrent.atomic.AtomicBoolean
5152import java.util.concurrent.atomic.AtomicLong
5253import java.util.concurrent.locks.ReentrantReadWriteLock
54+ import kotlin.concurrent.read
5355import kotlin.concurrent.write
5456
5557internal 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 }
0 commit comments