Fix #279: Add adaptive playback health monitoring and dynamic buffer …#284
Conversation
…ic buffer handling for IPTV
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds playback health monitoring and adaptive playback behavior around ExoPlayer, surfacing a lightweight UI indicator when network/playback quality degrades.
Changes:
- Injects a
DefaultTrackSelectorand a customLoadControlinto TV ExoPlayer creation. - Introduces periodic playback metrics collection and adaptive bitrate/buffer tuning (
PlaybackMetricsAnalyzer). - Displays an on-screen “Network health” indicator in TV playback UI.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt | Wires in track selector + adaptive load control, creates/releases metrics analyzer, and renders UI health indicator. |
| app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt | Adds imports for new playback health/adaptation components (usage not shown in diff). |
| app/src/main/kotlin/com/arflix/tv/playback/PlaybackMetricsAnalyzer.kt | New analyzer collecting metrics and adapting track selection/buffer strategy based on computed “health”. |
| app/src/main/kotlin/com/arflix/tv/playback/PlaybackHealthIndicator.kt | New Compose UI overlay to display degraded playback/network health states. |
| app/src/main/kotlin/com/arflix/tv/playback/NetworkAdaptiveLoadControl.kt | New LoadControl implementation with dynamic max-buffer adjustments. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| override fun onPlaybackStateChanged( | ||
| eventTime: AnalyticsListener.EventTime, | ||
| state: Int | ||
| ) { | ||
| if (state == Player.STATE_BUFFERING && isStartup && startupStartTime == 0L) { | ||
| startupStartTime = System.currentTimeMillis() | ||
| } else if (state == Player.STATE_READY && isStartup) { | ||
| val latency = System.currentTimeMillis() - startupStartTime | ||
| _metrics.value = _metrics.value.copy(startupLatencyMs = latency) | ||
| isStartup = false | ||
| } | ||
| evaluateHealthAndAdapt() | ||
| } |
| val currentPos = player.currentPosition | ||
| val bufferedPos = player.bufferedPosition | ||
|
|
||
| val playedDelta = currentPos - lastCurrentPosition | ||
| val bufferedDelta = bufferedPos - lastBufferedPosition | ||
|
|
||
| // Calculate depletion as a ratio: how much playhead advanced vs how much buffer advanced | ||
| val depletion = if (playedDelta > 0) { | ||
| (playedDelta - bufferedDelta).toFloat() / playedDelta.toFloat() | ||
| } else 0f | ||
|
|
||
| val oldAvg = _metrics.value.bufferDepletionRateAvg | ||
| val newAvg = oldAvg * 0.7f + depletion * 0.3f | ||
|
|
||
| _metrics.value = _metrics.value.copy(bufferDepletionRateAvg = newAvg) | ||
|
|
||
| lastCurrentPosition = currentPos | ||
| lastBufferedPosition = bufferedPos | ||
|
|
||
| evaluateHealthAndAdapt() |
| private fun evaluateHealthAndAdapt() { | ||
| val curr = _metrics.value | ||
| var newHealth = PlaybackHealth.EXCELLENT | ||
|
|
||
| if (curr.droppedFrames > 30 || curr.bufferDepletionRateAvg > 0.8f) { | ||
| newHealth = PlaybackHealth.CRITICAL | ||
| } else if (curr.droppedFrames > 10 || curr.bufferDepletionRateAvg > 0.5f) { | ||
| newHealth = PlaybackHealth.POOR | ||
| } else if (curr.bufferDepletionRateAvg > 0.2f) { | ||
| newHealth = PlaybackHealth.FAIR | ||
| } else if (curr.bufferDepletionRateAvg > 0f) { | ||
| newHealth = PlaybackHealth.GOOD | ||
| } | ||
|
|
||
| if (curr.health != newHealth) { | ||
| _metrics.value = curr.copy(health = newHealth) | ||
| adaptPlayback(newHealth) | ||
| } | ||
| } |
| override fun onTracksSelected( | ||
| timeline: Timeline, | ||
| mediaPeriodId: MediaPeriodId, | ||
| renderers: Array<Renderer>, | ||
| trackGroups: TrackGroupArray, | ||
| trackSelections: Array<ExoTrackSelection> | ||
| ) { |
| override fun shouldContinueLoading( | ||
| playbackPositionUs: Long, | ||
| bufferedDurationUs: Long, | ||
| playbackSpeed: Float | ||
| ): Boolean { | ||
| val targetBufferSizeReached = allocator.totalBytesAllocated >= targetBufferSize | ||
| val bufferedMs = bufferedDurationUs / 1000 | ||
|
|
||
| if (bufferedMs < minBufferMs) { | ||
| isBuffering = true | ||
| } else if (bufferedMs >= maxBufferMs || targetBufferSizeReached) { | ||
| isBuffering = false | ||
| } | ||
|
|
||
| return isBuffering | ||
| } |
| return ExoPlayer.Builder(context) | ||
| .setMediaSourceFactory(mediaSourceFactory) | ||
| .setLoadControl(loadControl) | ||
| .setTrackSelector(trackSelector) | ||
| .build() |
| @@ -0,0 +1,168 @@ | |||
| package com.arflix.tv.playback | |||
|
|
|||
| import androidx.media3.common.C | |||
|
Good idea, but this is not ready to merge yet. I tested a clean merge into current main and both compile tasks fail:
Main compile issues:
Also, the playback health logic needs some cleanup before merge:
The feature itself is useful, especially for IPTV stability, but please fix compile first and then clean up the health logic so it reflects current playback conditions, not old/invalid data. |
Issue
Fixes #279
Changes Made
Testing