diff --git a/README_VIDEO.MD b/README_VIDEO.MD index 4dbe8560..5e112cef 100644 --- a/README_VIDEO.MD +++ b/README_VIDEO.MD @@ -41,6 +41,7 @@ The video player leverages: - [Video Playback via URL or Local Files](#video-playback-via-url-or-local-files) - [Full Controls](#full-controls) - [Progress Indicators](#progress-indicators) + - [Buffered Ranges](#buffered-ranges) - [Error Handling](#error-handling) - [Loading Indicator](#loading-indicator) - [Using Subtitles](#using-subtitles) @@ -76,6 +77,7 @@ Try the online demo here : [๐ŸŽฅ Live Demo](https://kdroidfilter.github.io/Compo - **Picture-in-Picture (PiP)**: Continue watching in a floating window on Android (8.0+) and iOS. - **Audio Mode**: Configure audio interruption behavior and iOS silent switch handling. - **Video Caching**: Opt-in disk caching for video data on Android and iOS, ideal for scroll-based UIs. +- **Buffered Ranges**: Report how much of the media has been buffered, so you can draw a buffer indicator on the seek bar. - **Error handling** Simple error handling for network or playback issues. ## โœจ Supported Video Formats @@ -363,6 +365,42 @@ For programmatic seeking (e.g. skip forward/backward), use `seekTo` directly: playerState.seekTo(500f) ``` +### Buffered Ranges + +`VideoPlayerState` reports how much of the media has already been buffered, so you can draw a +buffer indicator behind the seek bar. + +```kotlin +if (playerState.isBufferedRangeSupported) { + // Single-bar indicator: how far playback can run from the current position, 0f..100f + LinearProgressIndicator(progress = { playerState.bufferedPercentage / 100f }) + + // Or draw every buffered span โ€” players can hold several disjoint ranges after a seek + playerState.bufferedRanges.forEach { range -> + println("buffered from ${range.start}s to ${range.end}s") + } +} +``` + +| Property | Description | +|----------|-------------| +| `isBufferedRangeSupported` | Whether this platform can report buffering for the current media. Check it first: `false` means "unknown", not "nothing buffered". | +| `bufferedRanges` | The buffered spans in seconds, sorted and free of overlaps. | +| `bufferedPercentage` | How far the media is buffered ahead of the current position, `0f..100f` of the total duration. | +| `bufferedSliderPos` | The same value on the `0f..1000f` scale used by `sliderPos`, so it can be drawn directly under the seek bar. | + +Platform behaviour: + +| Platform | Buffered ranges | +|----------|-----------------| +| Android | A single range starting at the playback position โ€” ExoPlayer exposes only one contiguous buffer. | +| iOS / macOS | Full multi-range support, from `AVPlayerItem.loadedTimeRanges`. | +| Web (JS / Wasm) | Full multi-range support, from `HTMLMediaElement.buffered`. | +| Linux | Multi-range for streamed sources (GStreamer buffering query); local files report the whole duration. | +| Windows โ€” HLS | Full multi-range support, from `IMFMediaEngine`. | +| Windows โ€” local files | The whole duration, which is always available on disk. | +| Windows โ€” progressive network | **Not supported.** The Media Foundation source reader exposes no buffering data, so `isBufferedRangeSupported` is `false`. | + ### Error Handling In case of an error, you can display it using `println`: diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt index d4b530d6..313f4b12 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt @@ -80,6 +80,9 @@ actual fun createVideoPlayerState( internal val androidVideoLogger = TaggedLogger("AndroidVideoPlayerSurface") +/** Cadence (ms) for polling ExoPlayer's buffer state. Matches the desktop backends. */ +private const val BUFFERED_RANGES_POLL_INTERVAL_MS = 200L + @UnstableApi @Stable open class DefaultVideoPlayerState( @@ -106,6 +109,7 @@ open class DefaultVideoPlayerState( private val context: Context = ContextProvider.getContext() internal var exoPlayer: ExoPlayer? = null private var updateJob: Job? = null + private var bufferedUpdateJob: Job? = null private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) // Protection against race conditions @@ -273,6 +277,14 @@ open class DefaultVideoPlayerState( override val currentTime: Double get() = _currentTime override val duration: Double get() = _duration + // Buffering. ExoPlayer exposes a single contiguous buffer starting at the playback position, + // so bufferedRanges holds at most one entry. + private var _bufferedRanges by mutableStateOf>(emptyList()) + private var _bufferedPercentage by mutableFloatStateOf(0f) + override val isBufferedRangeSupported: Boolean get() = true + override val bufferedRanges: List get() = _bufferedRanges + override val bufferedPercentage: Float get() = _bufferedPercentage + override val isPipSupported: Boolean get() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -289,6 +301,7 @@ open class DefaultVideoPlayerState( register(this) initializePlayer() registerScreenLockReceiver() + startBufferedRangeUpdates() } private fun shouldUseConservativeCodecHandling(): Boolean { @@ -623,6 +636,57 @@ open class DefaultVideoPlayerState( updateJob = null } + /** + * Polls the buffer state for as long as this state exists. It runs separately from + * [startPositionUpdates], which is stopped whenever playback pauses โ€” the buffer, on the other + * hand, keeps filling while paused. + */ + private fun startBufferedRangeUpdates() { + bufferedUpdateJob?.cancel() + bufferedUpdateJob = + coroutineScope.launch { + while (isActive) { + val player = exoPlayer + if (player != null && !isPlayerReleased && player.playbackState != Player.STATE_IDLE) { + updateBufferedRanges(player) + } + delay(BUFFERED_RANGES_POLL_INTERVAL_MS) + } + } + } + + private fun stopBufferedRangeUpdates() { + bufferedUpdateJob?.cancel() + bufferedUpdateJob = null + } + + /** + * Reads ExoPlayer's buffer state. [Player.getBufferedPosition] is the end of the single + * contiguous buffer held ahead of the playback position, so the resulting list never holds + * more than one range. + */ + private fun updateBufferedRanges(player: Player) { + val positionSeconds = player.currentPosition.toDouble() / 1000.0 + val bufferedSeconds = player.bufferedPosition.toDouble() / 1000.0 + + _bufferedRanges = + normalizeBufferedRanges( + listOf(BufferedRange(positionSeconds, bufferedSeconds)), + durationSeconds = _duration, + ) + _bufferedPercentage = + if (_duration > 0) { + player.bufferedPercentage.toFloat().coerceIn(0f, 100f) + } else { + 0f + } + } + + private fun resetBufferedRanges() { + _bufferedRanges = emptyList() + _bufferedPercentage = 0f + } + override fun openUri( uri: String, initializeplayerState: InitialPlayerState, @@ -871,6 +935,7 @@ open class DefaultVideoPlayerState( _aspectRatio = 16f / 9f _playbackSpeed = 1.0f _metadata = VideoMetadata() + resetBufferedRanges() exoPlayer?.playbackParameters = PlaybackParameters(_playbackSpeed) if (!keepMedia) { _hasMedia = false @@ -881,6 +946,7 @@ open class DefaultVideoPlayerState( synchronized(playerInitializationLock) { isPlayerReleased = true stopPositionUpdates() + stopBufferedRangeUpdates() coroutineScope.cancel() playerView?.player = null playerView = null diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/BufferedRange.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/BufferedRange.kt new file mode 100644 index 00000000..9bbc458e --- /dev/null +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/BufferedRange.kt @@ -0,0 +1,91 @@ +package io.github.kdroidfilter.composemediaplayer + +import androidx.compose.runtime.Immutable + +/** + * A contiguous span of media that has already been buffered (downloaded and ready to be decoded), + * expressed in seconds from the start of the media. + * + * Buffered ranges mirror the HTML5 `HTMLMediaElement.buffered` model: a player may hold several + * disjoint ranges, for example after seeking forward into an unbuffered part of a stream. + * + * @property start inclusive start of the range, in seconds + * @property end inclusive end of the range, in seconds + */ +@Immutable +data class BufferedRange( + val start: Double, + val end: Double, +) { + /** Length of the range in seconds. Never negative. */ + val duration: Double get() = (end - start).coerceAtLeast(0.0) + + /** Returns `true` when [positionSeconds] falls inside this range. */ + operator fun contains(positionSeconds: Double): Boolean = positionSeconds in start..end +} + +/** + * Returns the end (in seconds) of the buffered range containing [positionSeconds], i.e. how far + * playback can continue uninterrupted from that position. + * + * When no range covers the position, [positionSeconds] itself is returned, meaning "nothing is + * buffered ahead". + */ +fun List.bufferedEndAt(positionSeconds: Double): Double = + firstOrNull { positionSeconds in it }?.end ?: positionSeconds + +/** + * Returns how much of the media is buffered ahead of [positionSeconds], as a percentage + * (`0.0`โ€“`100.0`) of [durationSeconds]. Returns `0f` when the duration is unknown. + */ +fun List.bufferedPercentageAt( + positionSeconds: Double, + durationSeconds: Double, +): Float { + if (durationSeconds <= 0.0 || durationSeconds.isNaN() || durationSeconds.isInfinite()) return 0f + val end = bufferedEndAt(positionSeconds) + return ((end / durationSeconds) * 100.0).toFloat().coerceIn(0f, 100f) +} + +/** + * Cleans up raw ranges coming from a platform player: drops empty or non-finite entries, clamps + * them to `[0, durationSeconds]` when the duration is known, sorts them and merges the ones that + * overlap or touch. + * + * Backends report ranges with varying degrees of tidiness, so every implementation funnels its + * values through this function to give callers a single, predictable shape. + */ +internal fun normalizeBufferedRanges( + ranges: List, + durationSeconds: Double = 0.0, +): List { + val hasDuration = durationSeconds > 0.0 && !durationSeconds.isNaN() && !durationSeconds.isInfinite() + + val cleaned = + ranges + .asSequence() + .filter { it.start.isFinite() && it.end.isFinite() } + .map { range -> + val start = range.start.coerceAtLeast(0.0) + val end = if (hasDuration) range.end.coerceAtMost(durationSeconds) else range.end + BufferedRange(start, end) + }.filter { it.end > it.start } + .sortedBy { it.start } + .toList() + + if (cleaned.isEmpty()) return emptyList() + + val merged = ArrayList(cleaned.size) + var current = cleaned.first() + for (range in cleaned.drop(1)) { + current = + if (range.start <= current.end) { + BufferedRange(current.start, maxOf(current.end, range.end)) + } else { + merged.add(current) + range + } + } + merged.add(current) + return merged +} 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..36aa39a9 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,46 @@ interface VideoPlayerState { var isFullscreen: Boolean val aspectRatio: Float + // Buffering + + /** + * Indicates whether the current platform backend can report buffered ranges for the media + * being played. + * + * When this is `false`, [bufferedRanges] and [bufferedPercentage] stay empty/zero and carry no + * meaning โ€” the underlying player simply does not expose the information. Check this before + * drawing a buffer indicator, so an unsupported backend renders "unknown" rather than + * "nothing buffered". + * + * This is currently `false` only for progressive (non-HLS) network streams on the Windows + * desktop backend, whose Media Foundation source reader exposes no buffering data. + */ + val isBufferedRangeSupported: Boolean get() = false + + /** + * The spans of media that are already buffered, in seconds, sorted by [BufferedRange.start] + * and free of overlaps. + * + * Most backends report several disjoint ranges (for instance after seeking ahead in a stream). + * Android reports at most one range, starting at the current playback position, because + * ExoPlayer only exposes a single contiguous buffer. An empty list means nothing is buffered + * yet โ€” or, when [isBufferedRangeSupported] is `false`, that the information is unavailable. + */ + val bufferedRanges: List get() = emptyList() + + /** + * How far the media is buffered ahead of the current position, as a percentage (`0.0`โ€“`100.0`) + * of the total [duration]. Convenient for a single-bar buffer indicator; use [bufferedRanges] + * when you want to draw every buffered span. + */ + val bufferedPercentage: Float get() = 0f + + /** + * [bufferedPercentage] expressed on the same `0.0`โ€“`1000.0` scale as [sliderPos], so it can be + * drawn directly underneath the seek bar. + */ + val bufferedSliderPos: Float get() = bufferedPercentage * 10f + val isPipSupported: Boolean get() = false var isPipActive: Boolean get() = false set(value) {} @@ -309,6 +349,9 @@ data class PreviewableVideoPlayerState( override var isPipEnabled: Boolean = false, override var onPlaybackEnded: (() -> Unit)? = null, override var onRestart: (() -> Unit)? = null, + override val isBufferedRangeSupported: Boolean = false, + override val bufferedRanges: List = emptyList(), + override val bufferedPercentage: Float = 0f, ) : VideoPlayerState { override fun play() {} diff --git a/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/BufferedRangeTest.kt b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/BufferedRangeTest.kt new file mode 100644 index 00000000..878d0728 --- /dev/null +++ b/mediaplayer/src/commonTest/kotlin/io/github/kdroidfilter/composemediaplayer/BufferedRangeTest.kt @@ -0,0 +1,122 @@ +package io.github.kdroidfilter.composemediaplayer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BufferedRangeTest { + @Test + fun testDurationIsNeverNegative() { + assertEquals(5.0, BufferedRange(10.0, 15.0).duration) + assertEquals(0.0, BufferedRange(15.0, 10.0).duration) + } + + @Test + fun testContains() { + val range = BufferedRange(10.0, 20.0) + assertTrue(10.0 in range) + assertTrue(15.0 in range) + assertTrue(20.0 in range) + assertFalse(9.9 in range) + assertFalse(20.1 in range) + } + + @Test + fun testBufferedEndAt() { + val ranges = listOf(BufferedRange(0.0, 30.0), BufferedRange(60.0, 90.0)) + + assertEquals(30.0, ranges.bufferedEndAt(10.0)) + assertEquals(90.0, ranges.bufferedEndAt(70.0)) + // Nothing buffered ahead of a position that falls in the gap + assertEquals(45.0, ranges.bufferedEndAt(45.0)) + assertEquals(5.0, emptyList().bufferedEndAt(5.0)) + } + + @Test + fun testBufferedPercentageAt() { + val ranges = listOf(BufferedRange(0.0, 30.0)) + + assertEquals(30f, ranges.bufferedPercentageAt(10.0, 100.0)) + // Position outside any range -> only the played part counts + assertEquals(50f, ranges.bufferedPercentageAt(50.0, 100.0)) + // Unknown duration + assertEquals(0f, ranges.bufferedPercentageAt(10.0, 0.0)) + assertEquals(0f, ranges.bufferedPercentageAt(10.0, Double.NaN)) + } + + @Test + fun testBufferedPercentageIsClamped() { + // A player may report a range slightly past the announced duration + val ranges = listOf(BufferedRange(0.0, 105.0)) + assertEquals(100f, ranges.bufferedPercentageAt(10.0, 100.0)) + } + + @Test + fun testNormalizeSortsAndMergesRanges() { + val ranges = + normalizeBufferedRanges( + listOf( + BufferedRange(60.0, 90.0), + BufferedRange(0.0, 30.0), + BufferedRange(25.0, 40.0), + ), + ) + + assertEquals(listOf(BufferedRange(0.0, 40.0), BufferedRange(60.0, 90.0)), ranges) + } + + @Test + fun testNormalizeMergesTouchingRanges() { + val ranges = normalizeBufferedRanges(listOf(BufferedRange(0.0, 30.0), BufferedRange(30.0, 45.0))) + assertEquals(listOf(BufferedRange(0.0, 45.0)), ranges) + } + + @Test + fun testNormalizeDropsEmptyAndNonFiniteRanges() { + val ranges = + normalizeBufferedRanges( + listOf( + BufferedRange(10.0, 10.0), + BufferedRange(20.0, 15.0), + BufferedRange(Double.NaN, 5.0), + BufferedRange(0.0, Double.POSITIVE_INFINITY), + BufferedRange(5.0, 8.0), + ), + ) + + assertEquals(listOf(BufferedRange(5.0, 8.0)), ranges) + } + + @Test + fun testNormalizeClampsToDuration() { + val ranges = normalizeBufferedRanges(listOf(BufferedRange(-5.0, 120.0)), durationSeconds = 100.0) + assertEquals(listOf(BufferedRange(0.0, 100.0)), ranges) + } + + @Test + fun testNormalizeWithoutDurationKeepsEnds() { + val ranges = normalizeBufferedRanges(listOf(BufferedRange(0.0, 120.0))) + assertEquals(listOf(BufferedRange(0.0, 120.0)), ranges) + } + + @Test + fun testDefaultStateReportsNoBuffering() { + val state = PreviewableVideoPlayerState() + assertFalse(state.isBufferedRangeSupported) + assertEquals(emptyList(), state.bufferedRanges) + assertEquals(0f, state.bufferedPercentage) + assertEquals(0f, state.bufferedSliderPos) + } + + @Test + fun testBufferedSliderPosMatchesSliderScale() { + val state = + PreviewableVideoPlayerState( + isBufferedRangeSupported = true, + bufferedRanges = listOf(BufferedRange(0.0, 40.0)), + bufferedPercentage = 40f, + ) + assertEquals(400f, state.bufferedSliderPos) + } +} diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index 75992a8e..b54db2dc 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -18,6 +18,7 @@ import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import platform.AVFAudio.AVAudioSession import platform.AVFAudio.AVAudioSessionCategoryAmbient @@ -41,6 +42,7 @@ import platform.Foundation.NSKeyValueObservingOptions import platform.Foundation.NSKeyValueObservingProtocol import platform.Foundation.NSNotificationCenter import platform.Foundation.NSURL +import platform.Foundation.NSValue import platform.Foundation.addObserver import platform.Foundation.removeObserver import platform.UIKit.UIApplication @@ -156,6 +158,7 @@ open class DefaultVideoPlayerState( // KVO Observers private var timeControlStatusObserver: NSObject? = null private var statusObserver: NSObject? = null + private var loadedTimeRangesObserver: NSObject? = null // End-of-playback notification observer private var endObserver: Any? = null @@ -183,6 +186,13 @@ open class DefaultVideoPlayerState( override val currentTime: Double get() = _currentTime override val duration: Double get() = _duration + // Buffering, backed by AVPlayerItem.loadedTimeRanges (which can hold several disjoint ranges) + private var _bufferedRanges by mutableStateOf>(emptyList()) + private var _bufferedPercentage by mutableStateOf(0f) + override val isBufferedRangeSupported: Boolean get() = true + override val bufferedRanges: List get() = _bufferedRanges + override val bufferedPercentage: Float get() = _bufferedPercentage + // Observable video aspect ratio (default to 16:9) private var _videoAspectRatio by mutableStateOf(16.0 / 9.0) val videoAspectRatio: CGFloat @@ -263,6 +273,10 @@ open class DefaultVideoPlayerState( _positionText = if (currentSeconds.isNaN()) "00:00" else formatTime(currentSeconds.toFloat()) _durationText = if (durationSeconds.isNaN()) "00:00" else formatTime(durationSeconds.toFloat()) + // The buffered percentage is relative to the playhead, so refresh it as the + // position advances and not only when the buffer itself changes. + updateBufferedRanges(item) + item.presentationSize.useContents { if (width > 0 && height > 0) { _videoAspectRatio = width / height @@ -345,6 +359,15 @@ open class DefaultVideoPlayerState( } } + // KVO for loadedTimeRanges โ€” fires as the buffer grows, including while paused, which the + // periodic time observer alone would not cover. + loadedTimeRangesObserver = + item.observe("loadedTimeRanges") { _ -> + dispatch_async(dispatch_get_main_queue()) { + updateBufferedRanges(item) + } + } + // Periodic Time Observer startPositionUpdates(player) @@ -386,6 +409,35 @@ open class DefaultVideoPlayerState( timeObserverToken = null } + /** + * Reads [AVPlayerItem.loadedTimeRanges] into [bufferedRanges]. Each element is an [NSValue] + * boxing a `CMTimeRange`. Must be called on the main thread. + */ + private fun updateBufferedRanges(item: AVPlayerItem) { + val raw = + item.loadedTimeRanges.mapNotNull { value -> + (value as? NSValue)?.CMTimeRangeValue?.useContents { + val rangeStart = CMTimeGetSeconds(start.readValue()) + val rangeDuration = CMTimeGetSeconds(duration.readValue()) + if (rangeStart.isNaN() || rangeDuration.isNaN()) { + null + } else { + BufferedRange(rangeStart, rangeStart + rangeDuration) + } + } + } + + val durationSeconds = _duration + val ranges = normalizeBufferedRanges(raw, durationSeconds) + _bufferedRanges = ranges + _bufferedPercentage = ranges.bufferedPercentageAt(_currentTime, durationSeconds) + } + + private fun resetBufferedRanges() { + _bufferedRanges = emptyList() + _bufferedPercentage = 0f + } + private fun setupAppLifecycleObservers() { // Remove any existing observers first removeAppLifecycleObservers() @@ -500,6 +552,11 @@ open class DefaultVideoPlayerState( statusObserver = null } + loadedTimeRangesObserver?.let { + player?.currentItem?.removeObserver(it, forKeyPath = "loadedTimeRanges") + loadedTimeRangesObserver = null + } + endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) endObserver = null @@ -517,7 +574,7 @@ open class DefaultVideoPlayerState( player?.pause() player?.replaceCurrentItemWithPlayerItem(null) player = null - + resetBufferedRanges() } /** 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..f9edd57f 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 @@ -108,6 +108,12 @@ open class DefaultVideoPlayerState : VideoPlayerState { override val currentTime: Double get() = delegate.currentTime override val duration: Double get() = delegate.duration + // Must be forwarded explicitly: these members have interface defaults, so omitting them here + // would report "no buffering support" instead of asking the platform delegate. + override val isBufferedRangeSupported: Boolean get() = delegate.isBufferedRangeSupported + override val bufferedRanges: List get() = delegate.bufferedRanges + override val bufferedPercentage: Float get() = delegate.bufferedPercentage + override fun openUri( uri: String, initializeplayerState: InitialPlayerState, diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt index 4f7c89dd..f8197671 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxNativeBridge.kt @@ -68,6 +68,13 @@ internal object LinuxNativeBridge { @JvmStatic external fun nGetCurrentTime(handle: Long): Double + // Buffered ranges โ€” outRanges is filled with [start0, end0, start1, end1, ...] in seconds and + // its length caps how many ranges can be reported. Returns the number of ranges written. + @JvmStatic external fun nGetBufferedRanges( + handle: Long, + outRanges: DoubleArray, + ): Int + // Metadata @JvmStatic external fun nGetVideoTitle(handle: Long): String? 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..f3600c27 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 @@ -8,12 +8,17 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp +import io.github.kdroidfilter.composemediaplayer.BufferedRange import io.github.kdroidfilter.composemediaplayer.InitialPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError import io.github.kdroidfilter.composemediaplayer.VideoPlayerState +import io.github.kdroidfilter.composemediaplayer.bufferedPercentageAt +import io.github.kdroidfilter.composemediaplayer.normalizeBufferedRanges +import io.github.kdroidfilter.composemediaplayer.util.NATIVE_BUFFERED_RANGES_ARRAY_SIZE import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger +import io.github.kdroidfilter.composemediaplayer.util.decodeNativeBufferedRanges import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.* @@ -65,6 +70,7 @@ class LinuxVideoPlayerState : VideoPlayerState { private var playerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var frameUpdateJob: Job? = null private var bufferingCheckJob: Job? = null + private var bufferedRangesJob: Job? = null private var uiUpdateJob: Job? = null // State tracking @@ -122,6 +128,21 @@ class LinuxVideoPlayerState : VideoPlayerState { private val _aspectRatio = mutableStateOf(16f / 9f) override val aspectRatio: Float get() = _aspectRatio.value + // Buffering, read from GStreamer's buffering query through the native bridge. + private val _bufferedRanges = mutableStateOf>(emptyList()) + private val _bufferedPercentage = mutableStateOf(0f) + private val bufferedRangesBuffer = DoubleArray(NATIVE_BUFFERED_RANGES_ARRAY_SIZE) + + /** + * Turned off permanently if the bundled .so predates [LinuxNativeBridge.nGetBufferedRanges], + * so a stale native library degrades to "unsupported" instead of throwing on every poll. + */ + private var bufferedRangesAvailable by mutableStateOf(true) + + override val isBufferedRangeSupported: Boolean get() = bufferedRangesAvailable + override val bufferedRanges: List get() = _bufferedRanges.value + override val bufferedPercentage: Float get() = _bufferedPercentage.value + // Volume private val _volumeState = mutableStateOf(1.0f) override var volume: Float @@ -156,6 +177,7 @@ class LinuxVideoPlayerState : VideoPlayerState { private val bufferingCheckInterval = 200L private val bufferingTimeoutThreshold = 500L + private val bufferedRangesPollInterval = 200L init { linuxLogger.d { "Initializing Linux video player (JNI)" } @@ -264,6 +286,7 @@ class LinuxVideoPlayerState : VideoPlayerState { startFrameUpdates() updateFrameAsync() startBufferingCheck() + startBufferedRangesUpdates() if (isPlaying) { playInBackground() @@ -294,6 +317,7 @@ class LinuxVideoPlayerState : VideoPlayerState { pauseInBackground() stopFrameUpdates() stopBufferingCheck() + stopBufferedRangesUpdates() val ptrToDispose = withContext(frameDispatcher) { @@ -468,6 +492,63 @@ class LinuxVideoPlayerState : VideoPlayerState { bufferingCheckJob = null } + /** + * Polls the native buffering query on its own job: the buffer keeps filling while playback is + * paused, and both the frame loop and the stall check are stopped in that state. + */ + private fun startBufferedRangesUpdates() { + stopBufferedRangesUpdates() + bufferedRangesJob = + ioScope.launch { + while (isActive) { + ensureActive() + updateBufferedRangesAsync() + delay(bufferedRangesPollInterval) + } + } + } + + private fun stopBufferedRangesUpdates() { + bufferedRangesJob?.cancel() + bufferedRangesJob = null + } + + private suspend fun updateBufferedRangesAsync() { + if (!bufferedRangesAvailable) return + val ptr = playerPtr + if (ptr == 0L) return + + val count = + try { + LinuxNativeBridge.nGetBufferedRanges(ptr, bufferedRangesBuffer) + } catch (e: UnsatisfiedLinkError) { + bufferedRangesAvailable = false + linuxLogger.w { "Native library has no buffered-range support: ${e.message}" } + resetBufferedRanges() + return + } catch (e: Exception) { + if (e is CancellationException) throw e + linuxLogger.e { "Error reading buffered ranges: ${e.message}" } + return + } + + val duration = getDurationSafely() + val position = getPositionSafely() + val ranges = normalizeBufferedRanges(decodeNativeBufferedRanges(bufferedRangesBuffer, count), duration) + + withContext(Dispatchers.Main) { + _bufferedRanges.value = ranges + _bufferedPercentage.value = ranges.bufferedPercentageAt(position, duration) + } + } + + private suspend fun resetBufferedRanges() { + withContext(Dispatchers.Main) { + _bufferedRanges.value = emptyList() + _bufferedPercentage.value = 0f + } + } + private suspend fun updateFrameAsync() { withContext(frameDispatcher) { try { @@ -725,6 +806,7 @@ class LinuxVideoPlayerState : VideoPlayerState { override fun dispose() { stopFrameUpdates() stopBufferingCheck() + stopBufferedRangesUpdates() uiUpdateJob?.cancel() playerScope.cancel() @@ -838,6 +920,8 @@ class LinuxVideoPlayerState : VideoPlayerState { _positionText.value = "00:00" _durationText.value = "00:00" _aspectRatio.value = 16f / 9f + _bufferedRanges.value = emptyList() + _bufferedPercentage.value = 0f error = null } _currentFrameState.value = null diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt index f21427d6..72ec7fec 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt @@ -84,6 +84,13 @@ internal object MacNativeBridge { @JvmStatic external fun nGetCurrentTime(handle: Long): Double + // Buffered ranges โ€” outRanges is filled with [start0, end0, start1, end1, ...] in seconds and + // its length caps how many ranges can be reported. Returns the number of ranges written. + @JvmStatic external fun nGetBufferedRanges( + handle: Long, + outRanges: DoubleArray, + ): Int + // Metadata @JvmStatic external fun nGetVideoTitle(handle: Long): String? 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..99f618e7 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 @@ -11,12 +11,17 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp +import io.github.kdroidfilter.composemediaplayer.BufferedRange import io.github.kdroidfilter.composemediaplayer.InitialPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError import io.github.kdroidfilter.composemediaplayer.VideoPlayerState +import io.github.kdroidfilter.composemediaplayer.bufferedPercentageAt +import io.github.kdroidfilter.composemediaplayer.normalizeBufferedRanges +import io.github.kdroidfilter.composemediaplayer.util.NATIVE_BUFFERED_RANGES_ARRAY_SIZE import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger +import io.github.kdroidfilter.composemediaplayer.util.decodeNativeBufferedRanges import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.CancellationException @@ -126,6 +131,18 @@ class MacVideoPlayerState : VideoPlayerState { private var _currentTime by mutableStateOf(0.0) private var _duration by mutableStateOf(0.0) private var _progress by mutableStateOf(0f) + + // Buffering, read from AVPlayerItem.loadedTimeRanges through the native bridge. + private var _bufferedRanges by mutableStateOf>(emptyList()) + private var _bufferedPercentage by mutableStateOf(0f) + private val bufferedRangesBuffer = DoubleArray(NATIVE_BUFFERED_RANGES_ARRAY_SIZE) + + /** + * Turned off permanently if the bundled dylib predates [MacNativeBridge.nGetBufferedRanges], + * so a stale native library degrades to "unsupported" instead of throwing on every poll. + */ + private var bufferedRangesAvailable by mutableStateOf(true) + override var sliderPos: Float get() = _progress * 1000f set(value) { @@ -208,6 +225,10 @@ class MacVideoPlayerState : VideoPlayerState { override val currentTime: Double get() = _currentTime override val duration: Double get() = _duration + override val isBufferedRangeSupported: Boolean get() = bufferedRangesAvailable + override val bufferedRanges: List get() = _bufferedRanges + override val bufferedPercentage: Float get() = _bufferedPercentage + // Fullscreen state override var isFullscreen by mutableStateOf(false) @@ -384,6 +405,7 @@ class MacVideoPlayerState : VideoPlayerState { _currentTime = 0.0 _progress = 0f _duration = 0.0 + resetBufferedRanges() resetMetadata() _hasMedia = false userPaused = false @@ -692,11 +714,37 @@ class MacVideoPlayerState : VideoPlayerState { _progress = (pos / _duration).toFloat().coerceIn(0f, 1f) } } + // Buffering advances while paused too, so this is polled outside the play check. + updateBufferedRanges(ptr) } delay(POSITION_POLL_INTERVAL_MS) } } + /** Reads AVPlayerItem.loadedTimeRanges through the native bridge. */ + private fun updateBufferedRanges(ptr: Long) { + if (!bufferedRangesAvailable) return + + val count = + try { + MacNativeBridge.nGetBufferedRanges(ptr, bufferedRangesBuffer) + } catch (e: UnsatisfiedLinkError) { + bufferedRangesAvailable = false + macLogger.w { "Native library has no buffered-range support: ${e.message}" } + resetBufferedRanges() + return + } + + val ranges = normalizeBufferedRanges(decodeNativeBufferedRanges(bufferedRangesBuffer, count), _duration) + _bufferedRanges = ranges + _bufferedPercentage = ranges.bufferedPercentageAt(_currentTime, _duration) + } + + private fun resetBufferedRanges() { + _bufferedRanges = emptyList() + _bufferedPercentage = 0f + } + private suspend fun produceFrames() { while (scope.isActive && _hasMedia && !isDisposing.get()) { val ptr = playerPtr @@ -1083,6 +1131,7 @@ class MacVideoPlayerState : VideoPlayerState { _progress = 0f _currentTime = 0.0 _duration = 0.0 + resetBufferedRanges() isLoading = false _error = null userPaused = false diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/NativeBufferedRanges.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/NativeBufferedRanges.kt new file mode 100644 index 00000000..8eb303b9 --- /dev/null +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/NativeBufferedRanges.kt @@ -0,0 +1,31 @@ +package io.github.kdroidfilter.composemediaplayer.util + +import io.github.kdroidfilter.composemediaplayer.BufferedRange + +/** + * Maximum number of buffered ranges the desktop backends read from their native players. + * + * Players usually report one or two ranges; the cap only exists to keep the exchange buffer at a + * fixed size, and extra ranges are dropped by the native side. + */ +internal const val MAX_NATIVE_BUFFERED_RANGES = 16 + +/** Size of the `DoubleArray` a native `nGetBufferedRanges` call expects. */ +internal const val NATIVE_BUFFERED_RANGES_ARRAY_SIZE = MAX_NATIVE_BUFFERED_RANGES * 2 + +/** + * Decodes the flat `[start0, end0, start1, end1, ...]` layout the native players fill in. + * + * @param buffer the array handed to the native call + * @param count the number of ranges the native call reported writing + */ +internal fun decodeNativeBufferedRanges( + buffer: DoubleArray, + count: Int, +): List { + if (count <= 0) return emptyList() + val usable = minOf(count, buffer.size / 2) + return (0 until usable).map { index -> + BufferedRange(buffer[index * 2], buffer[index * 2 + 1]) + } +} diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge.kt index 145bcdb3..f91f29a8 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsNativeBridge.kt @@ -6,7 +6,7 @@ import java.nio.ByteBuffer internal object WindowsNativeBridge { /** Expected native API version โ€” must match NATIVE_VIDEO_PLAYER_VERSION in the DLL. */ - private const val EXPECTED_NATIVE_VERSION = 2 + private const val EXPECTED_NATIVE_VERSION = 3 init { NativeLibraryLoader.load("NativeVideoPlayer", WindowsNativeBridge::class.java) @@ -102,6 +102,17 @@ internal object WindowsNativeBridge { outPosition: LongArray, ): Int + /** + * Fills [outRanges] with `[start0, end0, start1, end1, ...]` in seconds, capped by the array + * length. Returns the number of ranges written, or `-1` when the backend playing this media + * cannot report buffering (progressive network playback through the Media Foundation source + * reader). + */ + @JvmStatic external fun nGetBufferedRanges( + handle: Long, + outRanges: DoubleArray, + ): Int + @JvmStatic external fun nSetPlaybackState( handle: Long, isPlaying: Boolean, 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..9f7d50e9 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 @@ -11,12 +11,17 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp +import io.github.kdroidfilter.composemediaplayer.BufferedRange import io.github.kdroidfilter.composemediaplayer.InitialPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError import io.github.kdroidfilter.composemediaplayer.VideoPlayerState +import io.github.kdroidfilter.composemediaplayer.bufferedPercentageAt +import io.github.kdroidfilter.composemediaplayer.normalizeBufferedRanges +import io.github.kdroidfilter.composemediaplayer.util.NATIVE_BUFFERED_RANGES_ARRAY_SIZE import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger +import io.github.kdroidfilter.composemediaplayer.util.decodeNativeBufferedRanges import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.CancellationException @@ -53,6 +58,9 @@ import kotlin.concurrent.write internal val windowsLogger = TaggedLogger("WindowsVideoPlayerState") +/** Cadence (ms) for polling the native buffering state. Matches the macOS/Linux backends. */ +private const val BUFFERED_RANGES_POLL_INTERVAL_MS = 200L + /** * Windows implementation of the video player state. * Handles media playback using Media Foundation on Windows platform. @@ -153,6 +161,15 @@ class WindowsVideoPlayerState : VideoPlayerState { private var _currentTime by mutableStateOf(0.0) private var _duration by mutableStateOf(0.0) private var _progress by mutableStateOf(0f) + + // Buffering. Media Foundation only reports buffered ranges for HLS (through IMFMediaEngine); + // local files are whole-file "buffered" by definition, and progressive network playback runs + // on IMFSourceReader, which exposes nothing โ€” the native layer flags that case and + // isBufferedRangeSupported turns false for it. + private var _bufferedRanges by mutableStateOf>(emptyList()) + private var _bufferedPercentage by mutableStateOf(0f) + private var _bufferedRangeSupported by mutableStateOf(false) + private val bufferedRangesBuffer = DoubleArray(NATIVE_BUFFERED_RANGES_ARRAY_SIZE) override var sliderPos: Float get() = _progress * 1000f set(value) { @@ -236,6 +253,10 @@ class WindowsVideoPlayerState : VideoPlayerState { override val durationText: String get() = formatTime(_duration) override val currentTime: Double get() = _currentTime override val duration: Double get() = _duration + + override val isBufferedRangeSupported: Boolean get() = _bufferedRangeSupported + override val bufferedRanges: List get() = _bufferedRanges + override val bufferedPercentage: Float get() = _bufferedPercentage private var errorMessage: String? by mutableStateOf(null) // Fullscreen state @@ -520,6 +541,7 @@ class WindowsVideoPlayerState : VideoPlayerState { _currentTime = 0.0 _progress = 0f _duration = 0.0 + resetBufferedRanges() _metadata = VideoMetadata() _hasMedia = false userPaused = false @@ -658,6 +680,48 @@ class WindowsVideoPlayerState : VideoPlayerState { private fun startVideoPipeline(): Job = scope.launch { launch { produceFrames() } launch { consumeFrames() } + launch { observeBufferedRanges() } + } + + /** + * Polls the native buffering state on its own coroutine: the buffer keeps growing while + * playback is paused, so this cannot ride on the frame pipeline. + */ + private suspend fun observeBufferedRanges() { + while (scope.isActive && _hasMedia && !isDisposing.get()) { + val instance = videoPlayerInstance + if (instance == 0L) break + updateBufferedRanges(instance) + delay(BUFFERED_RANGES_POLL_INTERVAL_MS) + } + } + + private fun updateBufferedRanges(instance: Long) { + val count = + try { + player.nGetBufferedRanges(instance, bufferedRangesBuffer) + } catch (e: UnsatisfiedLinkError) { + windowsLogger.w { "Native library has no buffered-range support: ${e.message}" } + resetBufferedRanges() + return + } + + if (count < 0) { + // Progressive network playback โ€” Media Foundation cannot tell us anything. + resetBufferedRanges() + return + } + + val ranges = normalizeBufferedRanges(decodeNativeBufferedRanges(bufferedRangesBuffer, count), _duration) + _bufferedRangeSupported = true + _bufferedRanges = ranges + _bufferedPercentage = ranges.bufferedPercentageAt(_currentTime, _duration) + } + + private fun resetBufferedRanges() { + _bufferedRangeSupported = false + _bufferedRanges = emptyList() + _bufferedPercentage = 0f } /** @@ -1015,6 +1079,7 @@ class WindowsVideoPlayerState : VideoPlayerState { _progress = 0f _currentTime = 0.0 _duration = 0.0 + resetBufferedRanges() isLoading = false errorMessage = null _error = null diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c index e8137358..9b32be28 100644 --- a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c +++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.c @@ -46,6 +46,7 @@ struct VideoPlayer { float volume; float playback_speed; int did_play_to_end; // atomic flag + int is_local_source; // 1 for file:// URIs, which are never network-buffered // Bus polling thread pthread_t bus_thread; @@ -328,6 +329,8 @@ int nvp_open_uri(VideoPlayer* p, const char* uri) { } } + p->is_local_source = g_str_has_prefix(resolved_uri, "file://") ? 1 : 0; + g_object_set(p->pipeline, "uri", resolved_uri, NULL); g_free(resolved_uri); g_object_set(p->pipeline, "volume", (gdouble)p->volume, NULL); @@ -446,6 +449,70 @@ double nvp_get_current_time(VideoPlayer* p) { return 0.0; } +// --------------------------------------------------------------------------- +// Buffering +// --------------------------------------------------------------------------- + +// Fills `out` with [start0, end0, start1, end1, ...] in seconds and returns the number of ranges +// written (never more than max_ranges). +// +// playbin answers GST_QUERY_BUFFERING through queue2, which is only present for streamed sources +// and reports its ranges in GST_FORMAT_PERCENT (0..GST_FORMAT_PERCENT_MAX) rather than in time โ€” +// hence the conversion against the media duration. Local files have no queue2 at all and are fully +// available by definition, so they short-circuit to a single whole-duration range. +int32_t nvp_get_buffered_ranges(VideoPlayer* p, double* out, int32_t max_ranges) { + if (!p || !out || max_ranges <= 0) return 0; + + double duration = nvp_get_duration(p); + if (duration <= 0.0) return 0; + + if (p->is_local_source) { + out[0] = 0.0; + out[1] = duration; + return 1; + } + + GstQuery* query = gst_query_new_buffering(GST_FORMAT_PERCENT); + if (!query) return 0; + + if (!gst_element_query(p->pipeline, query)) { + gst_query_unref(query); + return 0; + } + + // The element may answer in a different format than the one requested. + GstFormat format = GST_FORMAT_UNDEFINED; + gst_query_parse_buffering_range(query, &format, NULL, NULL, NULL); + + guint n_ranges = gst_query_get_n_buffering_ranges(query); + int32_t count = 0; + + for (guint i = 0; i < n_ranges && count < max_ranges; i++) { + gint64 range_start = 0, range_stop = 0; + if (!gst_query_parse_nth_buffering_range(query, i, &range_start, &range_stop)) continue; + if (range_start < 0 || range_stop <= range_start) continue; + + double start_seconds, stop_seconds; + if (format == GST_FORMAT_TIME) { + start_seconds = (double)range_start / (double)GST_SECOND; + stop_seconds = (double)range_stop / (double)GST_SECOND; + } else if (format == GST_FORMAT_PERCENT) { + start_seconds = (double)range_start / (double)GST_FORMAT_PERCENT_MAX * duration; + stop_seconds = (double)range_stop / (double)GST_FORMAT_PERCENT_MAX * duration; + } else { + // BYTES/DEFAULT ranges cannot be mapped onto the timeline reliably. + continue; + } + + out[count * 2] = start_seconds; + out[count * 2 + 1] = stop_seconds; + count++; + } + + gst_query_unref(query); + return count; +} + // --------------------------------------------------------------------------- // Metadata // --------------------------------------------------------------------------- diff --git a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h index a74609f8..96fea80e 100644 --- a/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h +++ b/mediaplayer/src/jvmMain/native/linux/NativeVideoPlayer.h @@ -37,6 +37,9 @@ int32_t nvp_set_output_size(VideoPlayer* p, int32_t width, int32_t height); double nvp_get_duration(VideoPlayer* p); double nvp_get_current_time(VideoPlayer* p); +// Buffering โ€” fills out with [start0, end0, ...] in seconds, returns the number of ranges written +int32_t nvp_get_buffered_ranges(VideoPlayer* p, double* out, int32_t max_ranges); + // Metadata (caller must free returned strings with free()) char* nvp_get_title(VideoPlayer* p); int64_t nvp_get_bitrate(VideoPlayer* p); diff --git a/mediaplayer/src/jvmMain/native/linux/jni_bridge.c b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c index 9eb7794f..6f92931c 100644 --- a/mediaplayer/src/jvmMain/native/linux/jni_bridge.c +++ b/mediaplayer/src/jvmMain/native/linux/jni_bridge.c @@ -78,6 +78,22 @@ static jdouble JNICALL jni_GetCurrentTime(JNIEnv* env, jclass cls, jlong handle) return handle ? nvp_get_current_time(toCtx(handle)) : 0.0; } +// Fills outRanges with [start0, end0, start1, end1, ...] in seconds and returns the number of +// ranges written. The array length caps how many ranges are reported. +static jint JNICALL jni_GetBufferedRanges(JNIEnv* env, jclass cls, jlong handle, jdoubleArray outRanges) { + if (!handle || !outRanges) return 0; + + jsize capacity = (*env)->GetArrayLength(env, outRanges) / 2; + if (capacity <= 0) return 0; + + jdouble* buffer = (*env)->GetDoubleArrayElements(env, outRanges, NULL); + if (!buffer) return 0; + + int32_t count = nvp_get_buffered_ranges(toCtx(handle), (double*)buffer, (int32_t)capacity); + (*env)->ReleaseDoubleArrayElements(env, outRanges, buffer, 0); + return (jint)count; +} + static void JNICALL jni_SeekTo(JNIEnv* env, jclass cls, jlong handle, jdouble time) { if (handle) nvp_seek_to(toCtx(handle), (double)time); } @@ -150,6 +166,7 @@ static const JNINativeMethod g_methods[] = { { "nSetOutputSize", "(JII)I", (void*)jni_SetOutputSize }, { "nGetVideoDuration", "(J)D", (void*)jni_GetVideoDuration }, { "nGetCurrentTime", "(J)D", (void*)jni_GetCurrentTime }, + { "nGetBufferedRanges", "(J[D)I", (void*)jni_GetBufferedRanges }, { "nSeekTo", "(JD)V", (void*)jni_SeekTo }, { "nDisposePlayer", "(J)V", (void*)jni_DisposePlayer }, { "nSetPlaybackSpeed", "(JF)V", (void*)jni_SetPlaybackSpeed }, diff --git a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift index 36d1318a..6197f272 100644 --- a/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift +++ b/mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift @@ -1281,6 +1281,30 @@ class MacVideoPlayer { /// Returns whether the player is currently buffering func getIsBuffering() -> Bool { return isBuffering } + /// Copies the item's loaded (buffered) time ranges into `out` as a flat + /// `[start0, end0, start1, end1, ...]` array of seconds. + /// + /// - Parameters: + /// - out: destination buffer, which must hold at least `maxRanges * 2` doubles + /// - maxRanges: capacity of `out` expressed in ranges + /// - Returns: the number of ranges written, `0` when nothing is buffered or no item is loaded. + func copyBufferedRanges(into out: UnsafeMutablePointer?, maxRanges: Int32) -> Int32 { + guard let out = out, maxRanges > 0, let item = player?.currentItem else { return 0 } + + var count: Int32 = 0 + for value in item.loadedTimeRanges { + if count >= maxRanges { break } + let range = value.timeRangeValue + let start = CMTimeGetSeconds(range.start) + let length = CMTimeGetSeconds(range.duration) + if start.isNaN || length.isNaN || length <= 0 { continue } + out[Int(count) * 2] = start + out[Int(count) * 2 + 1] = start + length + count += 1 + } + return count + } + /// Returns network status string func getNetworkStatus() -> String { return networkStatus } @@ -1503,6 +1527,17 @@ public func getVideoDuration(_ context: UnsafeMutableRawPointer?) -> Double { return player.getDuration() } +@_cdecl("getBufferedRanges") +public func getBufferedRanges( + _ context: UnsafeMutableRawPointer?, + _ outRanges: UnsafeMutablePointer?, + _ maxRanges: Int32 +) -> Int32 { + guard let context = context else { return 0 } + let player = Unmanaged.fromOpaque(context).takeUnretainedValue() + return player.copyBufferedRanges(into: outRanges, maxRanges: maxRanges) +} + @_cdecl("getCurrentTime") public func getCurrentTime(_ context: UnsafeMutableRawPointer?) -> Double { guard let context = context else { return 0 } diff --git a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c index cd8b4784..a8d312b5 100644 --- a/mediaplayer/src/jvmMain/native/macos/jni_bridge.c +++ b/mediaplayer/src/jvmMain/native/macos/jni_bridge.c @@ -26,6 +26,7 @@ extern float getScreenRefreshRate(void* ctx); extern float getCaptureFrameRate(void* ctx); extern double getVideoDuration(void* ctx); extern double getCurrentTime(void* ctx); +extern int32_t getBufferedRanges(void* ctx, double* outRanges, int32_t maxRanges); extern void seekTo(void* ctx, double time); extern void disposeVideoPlayer(void* ctx); extern void setPlaybackSpeed(void* ctx, float speed); @@ -135,6 +136,22 @@ static jdouble JNICALL jni_GetCurrentTime(JNIEnv* env, jclass cls, jlong handle) return handle ? getCurrentTime(toCtx(handle)) : 0.0; } +// Fills outRanges with [start0, end0, start1, end1, ...] in seconds and returns the number of +// ranges written. The array length caps how many ranges are reported. +static jint JNICALL jni_GetBufferedRanges(JNIEnv* env, jclass cls, jlong handle, jdoubleArray outRanges) { + if (!handle || !outRanges) return 0; + + jsize capacity = (*env)->GetArrayLength(env, outRanges) / 2; + if (capacity <= 0) return 0; + + jdouble* buffer = (*env)->GetDoubleArrayElements(env, outRanges, NULL); + if (!buffer) return 0; + + int32_t count = getBufferedRanges(toCtx(handle), (double*)buffer, (int32_t)capacity); + (*env)->ReleaseDoubleArrayElements(env, outRanges, buffer, 0); + return (jint)count; +} + static void JNICALL jni_SeekTo(JNIEnv* env, jclass cls, jlong handle, jdouble time) { if (handle) seekTo(toCtx(handle), (double)time); } @@ -208,6 +225,7 @@ static const JNINativeMethod g_methods[] = { { "nGetCaptureFrameRate", "(J)F", (void*)jni_GetCaptureFrameRate }, { "nGetVideoDuration", "(J)D", (void*)jni_GetVideoDuration }, { "nGetCurrentTime", "(J)D", (void*)jni_GetCurrentTime }, + { "nGetBufferedRanges", "(J[D)I", (void*)jni_GetBufferedRanges }, { "nSeekTo", "(JD)V", (void*)jni_SeekTo }, { "nDisposePlayer", "(J)V", (void*)jni_DisposePlayer }, { "nSetPlaybackSpeed", "(JF)V", (void*)jni_SetPlaybackSpeed }, diff --git a/mediaplayer/src/jvmMain/native/windows/HLSPlayer.cpp b/mediaplayer/src/jvmMain/native/windows/HLSPlayer.cpp index 26b84ca1..b81a1d84 100644 --- a/mediaplayer/src/jvmMain/native/windows/HLSPlayer.cpp +++ b/mediaplayer/src/jvmMain/native/windows/HLSPlayer.cpp @@ -350,6 +350,35 @@ HRESULT HLSPlayer::GetDuration(LONGLONG* pDuration) const { return S_OK; } +// IMFMediaEngine exposes buffering with the same shape as HTML5's HTMLMediaElement.buffered: +// an IMFMediaTimeRange holding zero or more [start, end] pairs, in seconds. +HRESULT HLSPlayer::GetBufferedRanges(double* pOut, INT32 maxRanges, INT32* pCount) const { + if (!pCount) return E_INVALIDARG; + *pCount = 0; + if (!m_pEngine || !pOut || maxRanges <= 0) return E_INVALIDARG; + + ComPtr buffered; + HRESULT hr = m_pEngine->GetBuffered(buffered.GetAddressOf()); + if (FAILED(hr) || !buffered) return FAILED(hr) ? hr : E_FAIL; + + DWORD length = buffered->GetLength(); + INT32 count = 0; + + for (DWORD i = 0; i < length && count < maxRanges; i++) { + double start = 0.0, end = 0.0; + if (FAILED(buffered->GetStart(i, &start))) continue; + if (FAILED(buffered->GetEnd(i, &end))) continue; + if (std::isnan(start) || std::isnan(end) || end <= start) continue; + + pOut[count * 2] = start; + pOut[count * 2 + 1] = end; + count++; + } + + *pCount = count; + return S_OK; +} + HRESULT HLSPlayer::GetPosition(LONGLONG* pPosition) const { if (!m_pEngine || !pPosition) return E_INVALIDARG; double pos = m_pEngine->GetCurrentTime(); diff --git a/mediaplayer/src/jvmMain/native/windows/HLSPlayer.h b/mediaplayer/src/jvmMain/native/windows/HLSPlayer.h index 18ffa17b..bcb1fdb1 100644 --- a/mediaplayer/src/jvmMain/native/windows/HLSPlayer.h +++ b/mediaplayer/src/jvmMain/native/windows/HLSPlayer.h @@ -45,6 +45,8 @@ class HLSPlayer : public IMFMediaEngineNotify { BOOL HasAudio() const { return TRUE; } void GetVideoSize(UINT32* pW, UINT32* pH) const; HRESULT GetDuration(LONGLONG* pDuration) const; + // Fills pOut with [start0, end0, ...] seconds; writes the range count to pCount. + HRESULT GetBufferedRanges(double* pOut, INT32 maxRanges, INT32* pCount) const; HRESULT GetPosition(LONGLONG* pPosition) const; HRESULT SetVolume(float vol); HRESULT GetVolume(float* pVol) const; diff --git a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp index 1f17d13d..1f3eeb90 100644 --- a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp +++ b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.cpp @@ -904,6 +904,37 @@ NATIVEVIDEOPLAYER_API HRESULT GetMediaDuration(const VideoPlayerInstance* pInsta return S_OK; } +// Buffered ranges, in seconds, written to pOut as [start0, end0, start1, end1, ...]. +// +// Three cases: +// * HLS streams run on IMFMediaEngine, which reports real buffered ranges. +// * Local files are fully available on disk, so the whole timeline is "buffered". +// * Progressive network playback runs on IMFSourceReader, which exposes no buffering +// information whatsoever โ€” S_FALSE tells the caller the data is unavailable, which is +// surfaced to Kotlin as VideoPlayerState.isBufferedRangeSupported == false. +NATIVEVIDEOPLAYER_API HRESULT GetBufferedRanges(const VideoPlayerInstance* pInstance, + double* pOut, INT32 maxRanges, INT32* pCount) { + if (!pInstance || !pOut || !pCount || maxRanges <= 0) return OP_E_INVALID_PARAMETER; + *pCount = 0; + + if (pInstance->pHLSPlayer) + return pInstance->pHLSPlayer->GetBufferedRanges(pOut, maxRanges, pCount); + + if (!pInstance->pSourceReader) return OP_E_NOT_INITIALIZED; + + if (!pInstance->bIsNetworkSource) { + LONGLONG duration = 0; + HRESULT hr = GetMediaDuration(pInstance, &duration); + if (FAILED(hr) || duration <= 0) return S_OK; // supported, but nothing to report yet + pOut[0] = 0.0; + pOut[1] = static_cast(duration) / 10000000.0; + *pCount = 1; + return S_OK; + } + + return S_FALSE; +} + NATIVEVIDEOPLAYER_API HRESULT GetMediaPosition(const VideoPlayerInstance* pInstance, LONGLONG* pPosition) { if (!pInstance || !pPosition) return OP_E_NOT_INITIALIZED; if (pInstance->pHLSPlayer) return pInstance->pHLSPlayer->GetPosition(pPosition); diff --git a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h index 9b155b0a..1952878b 100644 --- a/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h +++ b/mediaplayer/src/jvmMain/native/windows/NativeVideoPlayer.h @@ -12,7 +12,7 @@ #include // Native API version โ€” bump when the exported API changes. -#define NATIVE_VIDEO_PLAYER_VERSION 2 +#define NATIVE_VIDEO_PLAYER_VERSION 3 // Playback speed bounds โ€” kept in sync with // io.github.kdroidfilter.composemediaplayer.VideoPlayerState.{MIN,MAX}_PLAYBACK_SPEED. @@ -73,6 +73,9 @@ NATIVEVIDEOPLAYER_API HRESULT GetVideoFrameRate(const VideoPlayerInstance* pInst NATIVEVIDEOPLAYER_API HRESULT SeekMedia(VideoPlayerInstance* pInstance, LONGLONG llPosition); NATIVEVIDEOPLAYER_API HRESULT GetMediaDuration(const VideoPlayerInstance* pInstance, LONGLONG* pDuration); NATIVEVIDEOPLAYER_API HRESULT GetMediaPosition(const VideoPlayerInstance* pInstance, LONGLONG* pPosition); +// Buffered ranges in seconds as [start0, end0, ...]; S_FALSE means the backend cannot report them. +NATIVEVIDEOPLAYER_API HRESULT GetBufferedRanges(const VideoPlayerInstance* pInstance, + double* pOut, INT32 maxRanges, INT32* pCount); NATIVEVIDEOPLAYER_API HRESULT SetPlaybackState(VideoPlayerInstance* pInstance, BOOL bPlaying, BOOL bStop = FALSE); NATIVEVIDEOPLAYER_API HRESULT ShutdownMediaFoundation(); NATIVEVIDEOPLAYER_API HRESULT SetAudioVolume(VideoPlayerInstance* pInstance, float volume); diff --git a/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp b/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp index 6720ee2f..9849eee6 100644 --- a/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp +++ b/mediaplayer/src/jvmMain/native/windows/jni_bridge.cpp @@ -111,6 +111,28 @@ static jint JNICALL jni_GetMediaPosition(JNIEnv* env, jclass, jlong handle, jlon return hr; } +// Fills outRanges with [start0, end0, start1, end1, ...] in seconds. Returns the number of ranges +// written, or -1 when this media's backend cannot report buffering at all (progressive network +// playback through IMFSourceReader). +static jint JNICALL jni_GetBufferedRanges(JNIEnv* env, jclass, jlong handle, jdoubleArray outRanges) { + if (!handle || !outRanges) return 0; + + jsize capacity = env->GetArrayLength(outRanges) / 2; + if (capacity <= 0) return 0; + + jdouble* buffer = env->GetDoubleArrayElements(outRanges, nullptr); + if (!buffer) return 0; + + INT32 count = 0; + HRESULT hr = GetBufferedRanges(toInstance(handle), static_cast(buffer), + static_cast(capacity), &count); + env->ReleaseDoubleArrayElements(outRanges, buffer, 0); + + if (hr == S_FALSE) return -1; + if (FAILED(hr)) return 0; + return static_cast(count); +} + static jint JNICALL jni_SetPlaybackState(JNIEnv*, jclass, jlong handle, jboolean playing, jboolean stop) { return handle ? SetPlaybackState(toInstance(handle), playing ? TRUE : FALSE, stop ? TRUE : FALSE) : E_INVALIDARG; } @@ -217,6 +239,7 @@ static const JNINativeMethod g_methods[] = { { const_cast("nSeekMedia"), const_cast("(JJ)I"), (void*)jni_SeekMedia }, { const_cast("nGetMediaDuration"), const_cast("(J[J)I"), (void*)jni_GetMediaDuration }, { const_cast("nGetMediaPosition"), const_cast("(J[J)I"), (void*)jni_GetMediaPosition }, + { const_cast("nGetBufferedRanges"), const_cast("(J[D)I"), (void*)jni_GetBufferedRanges }, { const_cast("nSetPlaybackState"), const_cast("(JZZ)I"), (void*)jni_SetPlaybackState }, { const_cast("nShutdownMediaFoundation"), const_cast("()I"), (void*)jni_ShutdownMediaFoundation }, { const_cast("nSetAudioVolume"), const_cast("(JF)I"), (void*)jni_SetAudioVolume }, diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt index 8c3e94b5..8414a2fb 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt @@ -131,6 +131,13 @@ open class DefaultVideoPlayerState : VideoPlayerState { override val currentTime: Double get() = _currentTime override val duration: Double get() = _currentDuration.toDouble() + // Buffering, backed by HTMLMediaElement.buffered (which can hold several disjoint ranges) + private var _bufferedRanges by mutableStateOf>(emptyList()) + private var _bufferedPercentage by mutableStateOf(0f) + override val isBufferedRangeSupported: Boolean get() = true + override val bufferedRanges: List get() = _bufferedRanges + override val bufferedPercentage: Float get() = _bufferedPercentage + // Job for handling seek operations internal var seekJob: Job? = null @@ -312,6 +319,7 @@ open class DefaultVideoPlayerState : VideoPlayerState { _positionText = "00:00" _durationText = "00:00" _currentTime = 0.0 + resetBufferedRanges() // Note: We don't clear lastUri, so it can be used to replay the video } @@ -392,6 +400,22 @@ open class DefaultVideoPlayerState : VideoPlayerState { updatePosition(currentTime, duration, forceUpdate) } + /** + * Updates the buffered ranges read from the media element. + * + * @param ranges the raw `[start, end]` pairs taken from `HTMLMediaElement.buffered`, in seconds + */ + fun onBufferedRangesUpdate(ranges: List) { + val normalized = normalizeBufferedRanges(ranges, duration) + _bufferedRanges = normalized + _bufferedPercentage = normalized.bufferedPercentageAt(_currentTime, duration) + } + + internal fun resetBufferedRanges() { + _bufferedRanges = emptyList() + _bufferedPercentage = 0f + } + /** * Disposes of resources used by the player. */ diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt index 48a7be9b..f2bfe47d 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt @@ -378,6 +378,12 @@ internal fun setupVideoElement( events = mapOf( "timeupdate" to { event -> playerState.onTimeUpdateEvent(event) }, + // "progress" fires while data is fetched (including when paused); the others + // catch the buffer changes that follow a seek or a source switch. + "progress" to { event -> playerState.onBufferedRangesEvent(event) }, + "seeked" to { event -> playerState.onBufferedRangesEvent(event) }, + "loadedmetadata" to { event -> playerState.onBufferedRangesEvent(event) }, + "emptied" to { playerState.resetBufferedRanges() }, "ended" to { scope.launch { if (playerState.loop) { @@ -463,9 +469,35 @@ internal fun setupVideoElement( internal fun DefaultVideoPlayerState.onTimeUpdateEvent(event: Event) { (event.target as? HTMLVideoElement)?.let { onTimeUpdate(it.currentTime.toFloat(), it.duration.toFloat()) + onBufferedRangesUpdate(it.readBufferedRanges()) } } +internal fun DefaultVideoPlayerState.onBufferedRangesEvent(event: Event) { + (event.target as? HTMLVideoElement)?.let { + onBufferedRangesUpdate(it.readBufferedRanges()) + } +} + +/** + * Reads `HTMLMediaElement.buffered` into plain [BufferedRange]s. + * + * Indexing a `TimeRanges` object throws when the browser mutates it between the length check and + * the accessor call, so the whole read is guarded and simply yields what was collected so far. + */ +internal fun HTMLVideoElement.readBufferedRanges(): List { + val ranges = mutableListOf() + try { + val buffered = buffered + for (index in 0 until buffered.length) { + ranges.add(BufferedRange(buffered.start(index), buffered.end(index))) + } + } catch (e: Throwable) { + webVideoLogger.d { "Failed to read buffered ranges: ${e.message}" } + } + return ranges +} + internal fun HTMLVideoElement.setupMetadataListener( playerState: VideoPlayerState, onVideoRatioChange: (Float) -> Unit, diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt index f5416960..20a1f953 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt @@ -4,11 +4,13 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -51,8 +53,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -380,18 +384,23 @@ private fun ControlsOverlay( .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - // Seek bar - Slider( - value = playerState.sliderPos, - onValueChange = { playerState.seekStart(it) }, - onValueChangeFinished = { playerState.seekFinished() }, - valueRange = 0f..1000f, - colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = MaterialTheme.colorScheme.primary, - inactiveTrackColor = Color.White.copy(alpha = 0.3f), - ), - ) + // Seek bar, with the buffered ranges drawn behind the track + Box(modifier = Modifier.fillMaxWidth()) { + BufferedTrack(playerState) + + Slider( + value = playerState.sliderPos, + onValueChange = { playerState.seekStart(it) }, + onValueChangeFinished = { playerState.seekFinished() }, + valueRange = 0f..1000f, + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = MaterialTheme.colorScheme.primary, + // Kept translucent so the buffered ranges underneath stay readable + inactiveTrackColor = Color.White.copy(alpha = 0.15f), + ), + ) + } // Time + action icons row Row( @@ -399,11 +408,18 @@ private fun ControlsOverlay( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "${playerState.positionText} / ${playerState.durationText}", - color = Color.White.copy(alpha = 0.9f), - style = MaterialTheme.typography.bodySmall, - ) + Column { + Text( + text = "${playerState.positionText} / ${playerState.durationText}", + color = Color.White.copy(alpha = 0.9f), + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = bufferedSummary(playerState), + color = Color.White.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, + ) + } Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { OverlayIconButton(onClick = onSourceClick) { @@ -434,6 +450,67 @@ private fun OverlayIconButton(onClick: () -> Unit, content: @Composable () -> Un } } +/** + * One-line readout of the player's buffering state, e.g. `Buffered 42% ยท 0:00โ€“1:12`. + * + * Exists to make the buffered-range API observable while testing: it distinguishes "nothing + * buffered yet" from "this backend cannot report buffering" (Windows progressive streams). + */ +private fun bufferedSummary(playerState: VideoPlayerState): String { + if (!playerState.isBufferedRangeSupported) return "Buffered: not reported on this platform" + + val ranges = playerState.bufferedRanges + if (ranges.isEmpty()) return "Buffered: โ€”" + + val detail = ranges.joinToString(" ") { "${formatSeconds(it.start)}โ€“${formatSeconds(it.end)}" } + return "Buffered ${playerState.bufferedPercentage.toInt()}% ยท $detail" +} + +private fun formatSeconds(seconds: Double): String { + val total = seconds.toInt().coerceAtLeast(0) + val minutes = total / 60 + val secs = total % 60 + return "$minutes:${secs.toString().padStart(2, '0')}" +} + +/** + * Draws the player's buffered ranges as a track behind the seek bar. + * + * Renders nothing when the platform cannot report buffering (see + * [VideoPlayerState.isBufferedRangeSupported]) so an unsupported backend does not look like an + * empty buffer. The horizontal padding matches the Slider's thumb radius so both align. + */ +@Composable +private fun BoxScope.BufferedTrack(playerState: VideoPlayerState) { + val duration = playerState.duration + if (!playerState.isBufferedRangeSupported || duration <= 0.0) return + + val ranges = playerState.bufferedRanges + if (ranges.isEmpty()) return + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .align(Alignment.Center) + .padding(horizontal = 10.dp), + ) { + ranges.forEach { range -> + val startX = ((range.start / duration).toFloat().coerceIn(0f, 1f)) * size.width + val endX = ((range.end / duration).toFloat().coerceIn(0f, 1f)) * size.width + if (endX <= startX) return@forEach + + drawLine( + color = Color.White.copy(alpha = 0.45f), + start = Offset(startX, size.height / 2f), + end = Offset(endX, size.height / 2f), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) + } + } +} + // endregion // region Fullscreen overlay