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
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.arflix.tv.playback

import androidx.media3.common.C
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.LoadControl
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.MediaPeriodId
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.exoplayer.upstream.Allocator
import androidx.media3.exoplayer.upstream.DefaultAllocator

class NetworkAdaptiveLoadControl(
private val minBufferMs: Int = 15_000,
private var maxBufferMs: Int = 50_000,
private val bufferForPlaybackMs: Int = 1_500,
private val bufferForPlaybackAfterRebufferMs: Int = 3_000,
private val targetBufferBytes: Int = C.LENGTH_UNSET
) : LoadControl {

private val allocator = DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE)
private var targetBufferSize = 0
private var isBuffering = false

override fun onPrepared() {
targetBufferSize = 0
isBuffering = false
}

override fun onTracksSelected(
timeline: Timeline,
mediaPeriodId: MediaPeriodId,
renderers: Array<Renderer>,
trackGroups: TrackGroupArray,
trackSelections: Array<ExoTrackSelection>
) {
Comment on lines +30 to +36
// Calculate target buffer size
targetBufferSize = if (targetBufferBytes == C.LENGTH_UNSET) {
calculateTargetBufferSize(renderers, trackSelections)
} else {
targetBufferBytes
}
allocator.setTargetBufferSize(targetBufferSize)
}

override fun onStopped() {
targetBufferSize = 0
isBuffering = false
}

override fun onReleased() {
targetBufferSize = 0
isBuffering = false
}

override fun getAllocator(): Allocator = allocator

override fun getBackBufferDurationUs(): Long = 0

override fun retainBackBufferFromKeyframe(): Boolean = false

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
}
Comment on lines +62 to +77

override fun shouldStartPlayback(
timeline: Timeline,
mediaPeriodId: MediaPeriodId,
bufferedDurationUs: Long,
playbackSpeed: Float,
rebufferring: Boolean,
targetLiveOffsetUs: Long
): Boolean {
val bufferedMs = bufferedDurationUs / 1000
val minBuffer = if (rebufferring) bufferForPlaybackAfterRebufferMs else bufferForPlaybackMs
return bufferedMs >= (minBuffer * playbackSpeed).toLong()
}

private fun calculateTargetBufferSize(
renderers: Array<Renderer>,
trackSelections: Array<ExoTrackSelection>
): Int {
var targetBufferSize = 0
for (i in renderers.indices) {
if (trackSelections[i] != null) {
targetBufferSize += C.DEFAULT_VIDEO_BUFFER_SIZE // simplify
}
}
return Math.max(C.DEFAULT_BUFFER_SEGMENT_SIZE, targetBufferSize)
}

fun increaseBufferDynamically() {
// Increase max buffer dynamically when network degrades
maxBufferMs = (maxBufferMs * 1.5).toInt().coerceAtMost(120_000)
}

fun resetBuffer() {
maxBufferMs = 50_000
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.arflix.tv.playback

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.flow.StateFlow

@Composable
fun PlaybackHealthIndicator(
metricsFlow: StateFlow<PlaybackMetrics>,
modifier: Modifier = Modifier
) {
val metrics by metricsFlow.collectAsState()

val (color, text) = when (metrics.health) {
PlaybackHealth.EXCELLENT -> Color.Green to "Excellent"
PlaybackHealth.GOOD -> Color(0xFF8BC34A) to "Good" // Light Green
PlaybackHealth.FAIR -> Color.Yellow to "Fair"
PlaybackHealth.POOR -> Color(0xFFFF9800) to "Poor" // Orange
PlaybackHealth.CRITICAL -> Color.Red to "Critical"
}

// Only show if network is degrading to not distract users normally,
// or show always if requested. We'll show for FAIR, POOR, CRITICAL.
if (metrics.health == PlaybackHealth.EXCELLENT || metrics.health == PlaybackHealth.GOOD) {
return
}

Row(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Network: $text",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
168 changes: 168 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/playback/PlaybackMetricsAnalyzer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.arflix.tv.playback

import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

enum class PlaybackHealth {
EXCELLENT, GOOD, FAIR, POOR, CRITICAL
}

data class PlaybackMetrics(
val throughputBps: Long = 0,
val bufferDepletionRateAvg: Float = 0f,
val startupLatencyMs: Long = 0,
val droppedFrames: Int = 0,
val health: PlaybackHealth = PlaybackHealth.EXCELLENT
)

class PlaybackMetricsAnalyzer(
private val player: ExoPlayer,
private val trackSelector: DefaultTrackSelector?,
private val loadControl: NetworkAdaptiveLoadControl?,
coroutineScope: CoroutineScope
) : AnalyticsListener {

private val _metrics = MutableStateFlow(PlaybackMetrics())
val metrics: StateFlow<PlaybackMetrics> = _metrics.asStateFlow()

private var startupStartTime = 0L
private var isStartup = true
private var lastBufferedPosition = 0L
private var lastCurrentPosition = 0L
private var monitorJob: Job? = null

init {
player.addAnalyticsListener(this)
monitorJob = coroutineScope.launch {
while (true) {
delay(1000)
updatePeriodicMetrics()
}
}
}

override fun onBandwidthEstimate(
eventTime: AnalyticsListener.EventTime,
totalLoadTimeMs: Int,
totalBytesLoaded: Long,
bitrateEstimate: Long
) {
_metrics.value = _metrics.value.copy(throughputBps = bitrateEstimate)
evaluateHealthAndAdapt()
}

override fun onDroppedVideoFrames(
eventTime: AnalyticsListener.EventTime,
droppedFrames: Int,
elapsedMs: Long
) {
_metrics.value = _metrics.value.copy(
droppedFrames = _metrics.value.droppedFrames + droppedFrames
)
evaluateHealthAndAdapt()
}

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()
}
Comment on lines +75 to +87

private fun updatePeriodicMetrics() {
if (!player.isPlaying) return

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()
Comment on lines +92 to +111
}

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)
}
}
Comment on lines +114 to +132

private fun adaptPlayback(health: PlaybackHealth) {
if (trackSelector != null) {
val parametersBuilder = trackSelector.parameters.buildUpon()
when (health) {
PlaybackHealth.CRITICAL, PlaybackHealth.POOR -> {
parametersBuilder.setMaxVideoBitrate(1_000_000) // Lower quality
}
PlaybackHealth.FAIR -> {
parametersBuilder.setMaxVideoBitrate(3_000_000)
}
PlaybackHealth.GOOD, PlaybackHealth.EXCELLENT -> {
parametersBuilder.setMaxVideoBitrate(Int.MAX_VALUE)
}
}
trackSelector.parameters = parametersBuilder.build()
}

if (loadControl != null) {
when (health) {
PlaybackHealth.CRITICAL, PlaybackHealth.POOR -> {
loadControl.increaseBufferDynamically()
}
PlaybackHealth.GOOD, PlaybackHealth.EXCELLENT -> {
loadControl.resetBuffer()
}
else -> {}
}
}
}

fun release() {
player.removeAnalyticsListener(this)
monitorJob?.cancel()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.arflix.tv.playback.NetworkAdaptiveLoadControl
import com.arflix.tv.playback.PlaybackHealthIndicator
import com.arflix.tv.playback.PlaybackMetricsAnalyzer
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
Expand Down
Loading