From 874c3fe1a7700ecad69f5b2c43fa5e82ad3fe5c6 Mon Sep 17 00:00:00 2001 From: "federico.gambassi@tomatoai.it" Date: Fri, 30 Oct 2026 18:49:47 +0100 Subject: [PATCH] feat(desktop): expose whether the first frame has been rendered A caller that shows a loader or a placeholder over the video needs to know when there is a picture to show. isLoading is not that signal (it is forced off after three seconds without frames, and it covers seeks), and the decoded frame itself (currentFrameState) is internal. Add VideoPlayerState.hasRenderedFirstFrame: false after openUri/openFile until a frame of the new media has been handed to the surface. macOS and Windows derive it from currentFrameState, which every open clears; Linux keeps the last frame on the surface across an open, so it tracks the flag on its own. Other platforms report false for now. Co-Authored-By: Claude Opus 5.5 --- .../kdroidfilter/composemediaplayer/VideoPlayerState.kt | 8 ++++++++ .../composemediaplayer/VideoPlayerState.jvm.kt | 1 + .../composemediaplayer/linux/LinuxVideoPlayerState.kt | 6 ++++++ .../composemediaplayer/mac/MacVideoPlayerState.kt | 3 +++ .../composemediaplayer/windows/WindowsVideoPlayerState.kt | 3 +++ 5 files changed, 21 insertions(+) diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt index fc46cdf9..1c2cdf66 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt @@ -87,6 +87,14 @@ interface VideoPlayerState { var isFullscreen: Boolean val aspectRatio: Float + /** + * Whether a frame of the current media has been handed to the surface. False until the first frame after + * [openUri] / [openFile], so a caller can keep a placeholder or loader up until there is a picture to show. + * + * Reported by the desktop (JVM) players; false on the other platforms for now. + */ + val hasRenderedFirstFrame: Boolean get() = false + val isPipSupported: Boolean get() = false var isPipActive: Boolean get() = false set(value) {} diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt index e0323d2e..328e48f2 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt @@ -84,6 +84,7 @@ open class DefaultVideoPlayerState : VideoPlayerState { override val metadata: VideoMetadata get() = delegate.metadata override val aspectRatio: Float get() = delegate.aspectRatio + override val hasRenderedFirstFrame: Boolean get() = delegate.hasRenderedFirstFrame override var subtitlesEnabled = delegate.subtitlesEnabled override var currentSubtitleTrack: SubtitleTrack? = delegate.currentSubtitleTrack diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt index bb7d8ec5..ef7eb35b 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt @@ -47,6 +47,10 @@ class LinuxVideoPlayerState : VideoPlayerState { private val _currentFrameState = MutableStateFlow(null) internal val currentFrameState: State = mutableStateOf(null) + // The last frame stays on the surface across an open, so the first frame of a media is tracked on its own + private var _hasRenderedFirstFrame by mutableStateOf(false) + override val hasRenderedFirstFrame: Boolean get() = _hasRenderedFirstFrame + // Double-buffered Skia bitmaps private var skiaBitmapWidth: Int = 0 private var skiaBitmapHeight: Int = 0 @@ -174,6 +178,7 @@ class LinuxVideoPlayerState : VideoPlayerState { ensureActive() withContext(Dispatchers.Main) { (currentFrameState as MutableState).value = newFrame + if (newFrame != null) _hasRenderedFirstFrame = true } } } @@ -235,6 +240,7 @@ class LinuxVideoPlayerState : VideoPlayerState { isLoading = true error = null playbackSpeed = 1.0f + _hasRenderedFirstFrame = false } try { 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 035445b6..3ca4791e 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 @@ -183,6 +183,9 @@ class MacVideoPlayerState : VideoPlayerState { private val bitmapLock = ReentrantReadWriteLock() internal val currentFrameState = mutableStateOf(null) + // currentFrameState is cleared on every open (releaseAllResources) and set by the first consumed frame + override val hasRenderedFirstFrame: Boolean get() = currentFrameState.value != null + // Aspect ratio — driven by the live frame (see syncAspectRatioToFrame). private var _aspectRatio by mutableStateOf(16f / 9f) override val aspectRatio: Float get() = _aspectRatio diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt index a2eb89e7..f8f05343 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt @@ -207,6 +207,9 @@ class WindowsVideoPlayerState : VideoPlayerState { private val bitmapLock = ReentrantReadWriteLock() internal val currentFrameState = mutableStateOf(null) + // currentFrameState is cleared on every open (releaseAllResources) and set by the first consumed frame + override val hasRenderedFirstFrame: Boolean get() = currentFrameState.value != null + // Aspect ratio property override val aspectRatio: Float get() =