diff --git a/app/src/main/kotlin/com/arflix/tv/playback/NetworkAdaptiveLoadControl.kt b/app/src/main/kotlin/com/arflix/tv/playback/NetworkAdaptiveLoadControl.kt new file mode 100644 index 00000000..025f3e2c --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/playback/NetworkAdaptiveLoadControl.kt @@ -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, + trackGroups: TrackGroupArray, + trackSelections: Array + ) { + // 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 + } + + 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, + trackSelections: Array + ): 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 + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/playback/PlaybackHealthIndicator.kt b/app/src/main/kotlin/com/arflix/tv/playback/PlaybackHealthIndicator.kt new file mode 100644 index 00000000..97aa4379 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/playback/PlaybackHealthIndicator.kt @@ -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, + 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 + ) + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/playback/PlaybackMetricsAnalyzer.kt b/app/src/main/kotlin/com/arflix/tv/playback/PlaybackMetricsAnalyzer.kt new file mode 100644 index 00000000..ac2e1bbe --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/playback/PlaybackMetricsAnalyzer.kt @@ -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 = _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() + } + + 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() + } + + 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) + } + } + + 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() + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 8ff5059b..01fac198 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index 54b53b44..46f892e6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -100,6 +100,10 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +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.ExoPlayer import androidx.media3.exoplayer.hls.HlsMediaSource @@ -190,23 +194,14 @@ private fun String.toTvFocusZone( private fun createTvExoPlayer( context: Context, - mediaSourceFactory: DefaultMediaSourceFactory + mediaSourceFactory: DefaultMediaSourceFactory, + trackSelector: DefaultTrackSelector, + loadControl: LoadControl ): ExoPlayer { - val loadControl = DefaultLoadControl.Builder() - .setBufferDurationsMs( - 20_000, - 120_000, - 1_000, - 3_000 - ) - .setTargetBufferBytes(80 * 1024 * 1024) - .setPrioritizeTimeOverSizeThresholds(true) - .setBackBuffer(10_000, true) - .build() - return ExoPlayer.Builder(context) .setMediaSourceFactory(mediaSourceFactory) .setLoadControl(loadControl) + .setTrackSelector(trackSelector) .build() .apply { playWhenReady = true @@ -560,6 +555,10 @@ fun TvScreen( // Track whether ExoPlayer has been released to guard against post-dispose calls var isPlayerReleased by remember { mutableStateOf(false) } + val trackSelector = remember { DefaultTrackSelector(context) } + val loadControl = remember { NetworkAdaptiveLoadControl() } + val coroutineScope = rememberCoroutineScope() + var metricsAnalyzer by remember { mutableStateOf(null) } var exoPlayer by remember { mutableStateOf(null) } var miniPlayerView by remember { mutableStateOf(null) } @@ -572,6 +571,8 @@ fun TvScreen( DisposableEffect(Unit) { onDispose { isPlayerReleased = true + metricsAnalyzer?.release() + metricsAnalyzer = null exoPlayer?.release() exoPlayer = null } @@ -628,7 +629,9 @@ fun TvScreen( isFullScreen = true lastPreparedStreamUrl = initialStreamUrl if (exoPlayer == null) { - exoPlayer = createTvExoPlayer(context, iptvDefaultFactory) + exoPlayer = createTvExoPlayer(context, iptvDefaultFactory, trackSelector, loadControl).also { player -> + metricsAnalyzer = PlaybackMetricsAnalyzer(player, trackSelector, loadControl, coroutineScope) + } } prepareStream(initialStreamUrl) } @@ -649,7 +652,9 @@ fun TvScreen( } if (stream == lastPreparedStreamUrl) return@LaunchedEffect if (exoPlayer == null) { - exoPlayer = createTvExoPlayer(context, iptvDefaultFactory) + exoPlayer = createTvExoPlayer(context, iptvDefaultFactory, trackSelector, loadControl).also { player -> + metricsAnalyzer = PlaybackMetricsAnalyzer(player, trackSelector, loadControl, coroutineScope) + } } lastPreparedStreamUrl = stream playerRetryCount = 0 @@ -1313,6 +1318,15 @@ fun TvScreen( ) } + metricsAnalyzer?.let { analyzer -> + PlaybackHealthIndicator( + metricsFlow = analyzer.metrics, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 24.dp, end = 24.dp) + ) + } + // Mobile controls: back button + channel prev/next if (isMobile) { AnimatedVisibility(