Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README_VIDEO.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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<List<BufferedRange>>(emptyList())
private var _bufferedPercentage by mutableFloatStateOf(0f)
override val isBufferedRangeSupported: Boolean get() = true
override val bufferedRanges: List<BufferedRange> get() = _bufferedRanges
override val bufferedPercentage: Float get() = _bufferedPercentage

override val isPipSupported: Boolean
get() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Expand All @@ -289,6 +301,7 @@ open class DefaultVideoPlayerState(
register(this)
initializePlayer()
registerScreenLockReceiver()
startBufferedRangeUpdates()
}

private fun shouldUseConservativeCodecHandling(): Boolean {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -871,6 +935,7 @@ open class DefaultVideoPlayerState(
_aspectRatio = 16f / 9f
_playbackSpeed = 1.0f
_metadata = VideoMetadata()
resetBufferedRanges()
exoPlayer?.playbackParameters = PlaybackParameters(_playbackSpeed)
if (!keepMedia) {
_hasMedia = false
Expand All @@ -881,6 +946,7 @@ open class DefaultVideoPlayerState(
synchronized(playerInitializationLock) {
isPlayerReleased = true
stopPositionUpdates()
stopBufferedRangeUpdates()
coroutineScope.cancel()
playerView?.player = null
playerView = null
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BufferedRange>.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<BufferedRange>.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<BufferedRange>,
durationSeconds: Double = 0.0,
): List<BufferedRange> {
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<BufferedRange>(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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<BufferedRange> 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) {}
Expand Down Expand Up @@ -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<BufferedRange> = emptyList(),
override val bufferedPercentage: Float = 0f,
) : VideoPlayerState {
override fun play() {}

Expand Down
Loading