From cca67a2c7cdbbaf77926c7fee145770cb673938b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Tue, 6 Aug 2024 11:28:23 +0200 Subject: [PATCH] Dispaly metrics inside the demo apps --- gradle/libs.versions.toml | 1 + .../demo/shared/ui/components/Charts.kt | 18 +- .../demo/shared/ui/player/metrics/BitRates.kt | 46 ++ .../shared/ui/player/metrics/DataVolumes.kt | 31 + .../demo/shared/ui/player/metrics/Stalls.kt | 26 + .../player/metrics/StatsForNerdsViewModel.kt | 206 +++++++ .../settings/PlayerSettingsViewModel.kt | 10 + .../ui/player/settings/SettingsRoutes.kt | 5 + .../src/main/res/values/strings.xml | 22 + .../settings/PlaybackSettingsDrawer.kt | 23 + .../tv/ui/player/metrics/StatsForNerds.kt | 574 ++++++++++++++++++ pillarbox-demo/build.gradle.kts | 1 + .../demo/ui/components/DemoListItemView.kt | 70 ++- .../demo/ui/player/DemoPlayerView.kt | 106 +++- .../demo/ui/player/metrics/StatsForNerds.kt | 550 +++++++++++++++++ .../settings/PlaybackSettingsContent.kt | 44 +- .../demo/ui/settings/AppSettingsView.kt | 15 +- 17 files changed, 1701 insertions(+), 47 deletions(-) create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/BitRates.kt create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/DataVolumes.kt create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/Stalls.kt create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/StatsForNerdsViewModel.kt create mode 100644 pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/metrics/StatsForNerds.kt create mode 100644 pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/StatsForNerds.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd72539d8..cf180d223 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -125,6 +125,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-unit = { module = "androidx.compose.ui:ui-unit" } androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material3-window-size = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt index 5dee9acb1..fa85084dd 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt @@ -69,7 +69,7 @@ fun LineChart( lineWidth: Dp = 2.dp, lineCornerRadius: Dp = 6.dp, stretchChartToPointsCount: Int? = null, - scaleItemsCount: Int = 4, + scaleItemsCount: Int = 5, scaleTextFormatter: NumberFormat = NumberFormat.getIntegerInstance(), scaleTextStyle: TextStyle = TextStyle.Default, scaleTextHorizontalPadding: Dp = 8.dp, @@ -123,7 +123,7 @@ fun BarChart( barColor: Color = Color.Blue, barSpacing: Dp = 1.dp, stretchChartToPointsCount: Int? = null, - scaleItemsCount: Int = 4, + scaleItemsCount: Int = 5, scaleTextFormatter: NumberFormat = NumberFormat.getIntegerInstance(), scaleTextStyle: TextStyle = TextStyle.Default, scaleTextHorizontalPadding: Dp = 8.dp, @@ -154,13 +154,13 @@ fun BarChart( @Composable private fun Chart( data: List, - modifier: Modifier = Modifier, - stretchChartToPointsCount: Int? = null, - scaleItemsCount: Int = 4, + modifier: Modifier, + stretchChartToPointsCount: Int?, + scaleItemsCount: Int, scaleTextFormatter: NumberFormat, - scaleTextStyle: TextStyle = TextStyle.Default, - scaleTextHorizontalPadding: Dp = 8.dp, - scaleLineColor: Color = Color.LightGray, + scaleTextStyle: TextStyle, + scaleTextHorizontalPadding: Dp, + scaleLineColor: Color, drawChart: DrawScope.(points: List, maxValue: Int, bounds: Rect) -> Unit, ) { val trimmedData = if (stretchChartToPointsCount != null) data.takeLast(stretchChartToPointsCount) else data @@ -286,7 +286,7 @@ private fun DrawScope.drawScale( val textX = lineXEnd + scaleTextHorizontalPadding.toPx() val textY = (lineY - textSize.center.y).coerceIn( minimumValue = 0f, - maximumValue = size.height - textSize.height, + maximumValue = (size.height - textSize.height).coerceAtLeast(0f), ) drawLine( diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/BitRates.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/BitRates.kt new file mode 100644 index 000000000..30853e8ea --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/BitRates.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.player.metrics + +/** + * Information about bit rates. + * + * @property data The list of recorded bit rates. + */ +data class BitRates( + val data: List, +) { + /** + * The unit in which the bit rates are expressed. + */ + val unit = "Mbps" + + /** + * The current bit rate. + */ + val current: Float + get() = data.last() + + /** + * The biggest recorded bit rate. + */ + val max: Float + get() = data.max() + + /** + * The smallest recorded bit rate. + */ + val min: Float + get() = data.min() + + companion object { + /** + * Empty [BitRates]. + */ + val Empty = BitRates( + data = emptyList(), + ) + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/DataVolumes.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/DataVolumes.kt new file mode 100644 index 000000000..a74abd5f5 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/DataVolumes.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.player.metrics + +/** + * Information about data volumes. + * + * @property data The list of recorded data volumes. + * @property total The formatted total volume. + */ +data class DataVolumes( + val data: List, + val total: String, +) { + /** + * The unit in which the volumes are expressed. + */ + val unit = "MByte" + + companion object { + /** + * Empty [DataVolumes]. + */ + val Empty = DataVolumes( + data = emptyList(), + total = "", + ) + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/Stalls.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/Stalls.kt new file mode 100644 index 000000000..40bc6d6fd --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/Stalls.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.player.metrics + +/** + * Information about stalls. + * + * @property data The list of recorded stalls. + * @property total The formatted total of stalls. + */ +data class Stalls( + val data: List, + val total: String, +) { + companion object { + /** + * Empty [Stalls]. + */ + val Empty = Stalls( + data = emptyList(), + total = "", + ) + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/StatsForNerdsViewModel.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/StatsForNerdsViewModel.kt new file mode 100644 index 000000000..e66641d16 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/metrics/StatsForNerdsViewModel.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.player.metrics + +import android.app.Application +import androidx.annotation.StringRes +import androidx.lifecycle.AndroidViewModel +import androidx.media3.common.VideoSize +import ch.srgssr.pillarbox.demo.shared.R +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.text.NumberFormat +import kotlin.collections.sum +import kotlin.time.Duration + +/** + * [ViewModel][androidx.lifecycle.ViewModel] for the "Stats for Nerds" screen. + */ +class StatsForNerdsViewModel(application: Application) : AndroidViewModel(application) { + private val _indicatedBitRates = MutableStateFlow(BitRates.Empty) + + /** + * Provides information about the indicated bit rates. + */ + val indicatedBitRates: StateFlow = _indicatedBitRates + + private val _information = MutableStateFlow(emptyMap()) + + /** + * Provides information about the current session. + */ + val information: StateFlow> = _information + + private val _observedBitRates = MutableStateFlow(BitRates.Empty) + + /** + * Provides information about the observed bit rates. + */ + val observedBitRates: StateFlow = _observedBitRates + + private val _stalls = MutableStateFlow(Stalls.Empty) + + /** + * Provides information about stalls. + */ + val stalls: StateFlow = _stalls + + private val _startupTimes = MutableStateFlow(emptyMap()) + + /** + * Provides information about the startup times. + */ + val startupTimes: StateFlow> = _startupTimes + + private val _volumes = MutableStateFlow(DataVolumes.Empty) + + /** + * Provides information about volumes. + */ + val volumes: StateFlow = _volumes + + /** + * The latest playback metrics. + */ + var playbackMetrics: PlaybackMetrics? = null + set(value) { + if (field == value) { + return + } + + field = value + + if (value == null) { + return + } + + _indicatedBitRates.update { + BitRates( + data = it.data + (value.indicatedBitrate / TO_MEGA), + ) + } + + _information.update { + listOfNotNull( + getSessionInformation(R.string.session_id, value.sessionId), + getSessionInformation(R.string.media_uri, value.url?.toString()), + getSessionInformation(R.string.playback_duration, value.playbackDuration.toString()), + getSessionInformation(R.string.data_volume, value.totalBytesLoaded.toFloat().toFormattedBytes(includeUnit = true)), + getSessionInformation(R.string.buffering, value.bufferingDuration.toString()), + getSessionInformation( + labelRes = R.string.video_size, + value = if (value.videoSize != VideoSize.UNKNOWN) { + "${value.videoSize.width}x${value.videoSize.height}" + } else { + null + } + ), + ).toMap() + } + + _observedBitRates.update { + BitRates( + data = it.data + (value.bandwidth / TO_MEGA), + ) + } + + _stalls.update { + val stallCount = value.stallCount.toFloat() + val stall = if (it.data.isEmpty()) { + stallCount + } else { + stallCount - it.data.sum() + } + + Stalls( + data = it.data + stall.coerceAtLeast(0f), + total = value.stallCount.toFloat().toFormattedBytes(includeUnit = false) + ) + } + + _startupTimes.update { + listOfNotNull( + getLoadDuration(R.string.asset_loading, value.loadDuration.asset), + getLoadDuration(R.string.manifest_loading, value.loadDuration.manifest), + getLoadDuration(R.string.drm_loading, value.loadDuration.drm), + getLoadDuration(R.string.resource_loading, value.loadDuration.source), + getLoadDuration(R.string.total_load_time, value.loadDuration.timeToReady) + ).toMap() + } + + _volumes.update { + val totalBytesLoaded = value.totalBytesLoaded.toFloat() + val volume = if (it.data.isEmpty()) { + totalBytesLoaded / TO_MEGA + } else { + totalBytesLoaded / TO_MEGA - it.data.sum() + } + + DataVolumes( + data = it.data + volume.coerceAtLeast(0f), + total = totalBytesLoaded.toFormattedBytes(includeUnit = true), + ) + } + } + + private fun getLoadDuration( + @StringRes labelRes: Int, + duration: Duration?, + ): Pair? { + return if (duration != null) { + getApplication().getString(labelRes) to duration.toString() + } else { + null + } + } + + private fun getSessionInformation( + @StringRes labelRes: Int, + value: String?, + ): Pair? { + return if (value != null) { + getApplication().getString(labelRes) to value + } else { + null + } + } + + private fun Float.toFormattedBytes( + includeUnit: Boolean, + ): String { + val units = listOf("B", "KB", "MB", "GB", "TB") + val numberFormat = NumberFormat.getNumberInstance() + + var remaining = this + var unitIndex = 0 + while (remaining >= TO_NEXT_UNIT && unitIndex < units.lastIndex) { + remaining /= TO_NEXT_UNIT + unitIndex++ + } + + return if (includeUnit) { + "${numberFormat.format(remaining)} ${units[unitIndex]}" + } else { + numberFormat.format(remaining) + } + } + + companion object { + private const val TO_MEGA = 1_000_000f + private const val TO_NEXT_UNIT = 1_000f + + /** + * The aspect ratio to use for the charts. + */ + const val CHART_ASPECT_RATIO = 16 / 9f + + /** + * The maximum number of points to display on a chart. + */ + const val CHART_MAX_POINTS = 90 + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt index b4ac8681f..3405ca501 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt @@ -7,6 +7,7 @@ package ch.srgssr.pillarbox.demo.shared.ui.player.settings import android.app.Application import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.RecordVoiceOver import androidx.compose.material.icons.filled.SlowMotionVideo import androidx.compose.material.icons.filled.Tune @@ -180,6 +181,15 @@ class PlayerSettingsViewModel( ) ) } + + add( + SettingItem( + title = application.getString(R.string.stats_for_nerds), + subtitle = null, + icon = Icons.Default.Info, + destination = SettingsRoutes.StatsForNerds, + ) + ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/SettingsRoutes.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/SettingsRoutes.kt index 9da35c1be..366e8cd6d 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/SettingsRoutes.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/SettingsRoutes.kt @@ -34,4 +34,9 @@ sealed class SettingsRoutes(val route: String) { * The route for the video track setting. */ data object VideoTrack : SettingsRoutes(route = "settings/video_track") + + /** + * The route for the "Stats for nerds" screen. + */ + data object StatsForNerds : SettingsRoutes(route = "settings/stats_for_nerds") } diff --git a/pillarbox-demo-shared/src/main/res/values/strings.xml b/pillarbox-demo-shared/src/main/res/values/strings.xml index 96fa8c1eb..189252eca 100644 --- a/pillarbox-demo-shared/src/main/res/values/strings.xml +++ b/pillarbox-demo-shared/src/main/res/values/strings.xml @@ -14,6 +14,28 @@ Settings Audio tracks Video tracks + Stats for nerds + Startup times + Asset loading + Manifest loading + Resource loading + DRM loading + Total load time + Information + Session id + URI + Playback duration + Data volume + Stalls + Buffering + Video size + Indicated bitrate + Min. %s + Cur. %s + Max. %s + Observed bitrate + Total %s + Total %s Subtitles Speed Normal diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt index ba32aa428..58bb29b84 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.HearingDisabled @@ -35,6 +36,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.Format import androidx.media3.common.Player @@ -56,13 +58,18 @@ import ch.srgssr.pillarbox.demo.shared.R import ch.srgssr.pillarbox.demo.shared.ui.player.settings.PlayerSettingsViewModel import ch.srgssr.pillarbox.demo.shared.ui.player.settings.SettingsRoutes import ch.srgssr.pillarbox.demo.shared.ui.player.settings.TracksSettingItem +import ch.srgssr.pillarbox.demo.tv.ui.player.metrics.StatsForNerds import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.currentPositionAsFlow import ch.srgssr.pillarbox.player.extension.displayName import ch.srgssr.pillarbox.player.extension.hasAccessibilityRoles import ch.srgssr.pillarbox.player.extension.isForced import ch.srgssr.pillarbox.player.tracks.AudioTrack import ch.srgssr.pillarbox.player.tracks.Track import ch.srgssr.pillarbox.player.tracks.VideoTrack +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.seconds /** * Drawer used to display a player's settings. @@ -94,6 +101,7 @@ fun PlaybackSettingsDrawer( NavigationDrawerNavHost( player = player, modifier = Modifier + .width(320.dp) .fillMaxHeight() .padding(MaterialTheme.paddings.baseline) .background( @@ -230,6 +238,21 @@ private fun NavigationDrawerScope.NavigationDrawerNavHost( } ) } + + composable(SettingsRoutes.StatsForNerds.route) { + if (player !is PillarboxExoPlayer) { + return@composable + } + + val playbackMetrics by remember(player) { + player.currentPositionAsFlow(updateInterval = 1.seconds) + .map { player.getCurrentMetrics() } + }.collectAsState(player.getCurrentMetrics()) + + playbackMetrics?.let { + StatsForNerds(it) + } + } } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/metrics/StatsForNerds.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/metrics/StatsForNerds.kt new file mode 100644 index 000000000..2b5e45264 --- /dev/null +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/metrics/StatsForNerds.kt @@ -0,0 +1,574 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.tv.ui.player.metrics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.ListItem +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.LocalTextStyle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import ch.srgssr.pillarbox.demo.shared.R +import ch.srgssr.pillarbox.demo.shared.ui.components.BarChart +import ch.srgssr.pillarbox.demo.shared.ui.components.LineChart +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.BitRates +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.DataVolumes +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.Stalls +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.StatsForNerdsViewModel +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.StatsForNerdsViewModel.Companion.CHART_ASPECT_RATIO +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.StatsForNerdsViewModel.Companion.CHART_MAX_POINTS +import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme +import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun StatsForNerds( + playbackMetrics: PlaybackMetrics, + modifier: Modifier = Modifier, +) { + val statsForNerdsViewModel = viewModel(key = playbackMetrics.sessionId) + val startupTimes by statsForNerdsViewModel.startupTimes.collectAsState() + val information by statsForNerdsViewModel.information.collectAsState() + val indicatedBitRates by statsForNerdsViewModel.indicatedBitRates.collectAsState() + val observedBitRates by statsForNerdsViewModel.observedBitRates.collectAsState() + val volumes by statsForNerdsViewModel.volumes.collectAsState() + val stalls by statsForNerdsViewModel.stalls.collectAsState() + + LaunchedEffect(playbackMetrics) { + statsForNerdsViewModel.playbackMetrics = playbackMetrics + } + + Column( + modifier = modifier + .padding(horizontal = MaterialTheme.paddings.baseline) + .padding(top = MaterialTheme.paddings.baseline) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.stats_for_nerds), + style = MaterialTheme.typography.titleMedium, + ) + + StartupTimes(startupTimes) + + Information(information) + + IndicatedBitrate(indicatedBitRates) + + ObservedBitrate(observedBitRates) + + DataVolume(volumes) + + Stalls(stalls) + } +} + +@Composable +private fun StartupTimes( + loadDurations: Map, + modifier: Modifier = Modifier, +) { + if (loadDurations.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.startup_times), + modifier = modifier, + ) { + loadDurations.forEach { (label, duration) -> + NavigationItem( + content = { Text(text = label) }, + supportingContent = { Text(text = duration) }, + ) + } + } +} + +@Composable +private fun Information( + information: Map, + modifier: Modifier = Modifier, +) { + if (information.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.media_information), + modifier = modifier, + ) { + information.forEach { (label, value) -> + NavigationItem( + content = { Text(text = label) }, + supportingContent = { Text(text = value) }, + ) + } + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun IndicatedBitrate( + bitRates: BitRates, + modifier: Modifier = Modifier, +) { + if (bitRates.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.indicated_bitrate), + modifier = modifier, + ) { + NavigationItem( + content = { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text( + text = bitRates.unit, + modifier = Modifier + .align(Alignment.End) + .padding(vertical = MaterialTheme.paddings.small) + .padding(end = MaterialTheme.paddings.small), + ) + + LineChart( + data = bitRates.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + FlowRow( + modifier = Modifier.padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = stringResource(R.string.minimum_value, bitRates.min)) + + Text(text = stringResource(R.string.current_value, bitRates.current)) + + Text(text = stringResource(R.string.maximum_value, bitRates.max)) + } + } + }, + ) + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun ObservedBitrate( + bitRates: BitRates, + modifier: Modifier = Modifier, +) { + if (bitRates.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.observed_bitrate), + modifier = modifier, + ) { + NavigationItem( + content = { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text( + text = bitRates.unit, + modifier = Modifier + .align(Alignment.End) + .padding(vertical = MaterialTheme.paddings.small) + .padding(end = MaterialTheme.paddings.small), + ) + + LineChart( + data = bitRates.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + lineColor = Color.Blue, + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + FlowRow( + modifier = Modifier.padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = stringResource(R.string.minimum_value, bitRates.min)) + + Text(text = stringResource(R.string.current_value, bitRates.current)) + + Text(text = stringResource(R.string.maximum_value, bitRates.max)) + } + } + }, + ) + } +} + +@Composable +private fun DataVolume( + volumes: DataVolumes, + modifier: Modifier = Modifier, +) { + if (volumes.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.data_volume), + modifier = modifier, + ) { + NavigationItem( + content = { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text( + text = volumes.unit, + modifier = Modifier + .align(Alignment.End) + .padding(vertical = MaterialTheme.paddings.small) + .padding(end = MaterialTheme.paddings.small), + ) + + BarChart( + data = volumes.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + barColor = Color.Cyan, + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + Text( + text = stringResource(R.string.total_volume, volumes.total), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + ) + } + }, + ) + } +} + +@Composable +private fun Stalls( + stalls: Stalls, + modifier: Modifier = Modifier, +) { + if (stalls.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.stalls), + modifier = modifier, + ) { + NavigationItem( + content = { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + LineChart( + data = stalls.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + lineColor = Color.Yellow, + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + Text( + text = stringResource(R.string.total_stalls, stalls.total), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + ) + } + }, + ) + } +} + +@Composable +private fun Section( + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier) { + Text( + text = title, + modifier = Modifier.padding(vertical = MaterialTheme.paddings.baseline), + style = MaterialTheme.typography.titleSmall, + ) + + content() + } +} + +@Composable +private fun NavigationItem( + modifier: Modifier = Modifier, + content: (@Composable () -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, +) { + ListItem( + selected = false, + onClick = {}, + headlineContent = { content?.invoke() }, + modifier = modifier, + supportingContent = supportingContent, + ) +} + +@Composable +@Preview(showBackground = true) +private fun StartupTimesPreview() { + val asset = 123.milliseconds + val manifest = 456.milliseconds + val drm = 789.milliseconds + val source = 987.milliseconds + + PillarboxTheme { + Column { + StartupTimes( + loadDurations = mapOf( + stringResource(R.string.asset_loading) to asset.toString(), + stringResource(R.string.manifest_loading) to manifest.toString(), + stringResource(R.string.drm_loading) to drm.toString(), + stringResource(R.string.resource_loading) to source.toString(), + stringResource(R.string.total_load_time) to (asset + manifest + drm + source).toString(), + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun StartupTimesEmptyPreview() { + PillarboxTheme { + Column { + StartupTimes(loadDurations = emptyMap()) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InformationPreview() { + PillarboxTheme { + Column { + Information( + information = mapOf( + stringResource(R.string.session_id) to "abcdef-123456-ghijkl", + stringResource(R.string.media_uri) to "https://www.google.com/", + stringResource(R.string.playback_duration) to 42.seconds.toString(), + stringResource(R.string.data_volume) to "123.456 MB", + stringResource(R.string.buffering) to 3.seconds.toString(), + stringResource(R.string.video_size) to "1280x720", + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InformationEmptyPreview() { + PillarboxTheme { + Column { + Information( + information = emptyMap(), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun IndicatedBitratePreview() { + val dataSize = 10 + + PillarboxTheme { + Column { + IndicatedBitrate( + bitRates = BitRates( + data = buildList(dataSize) { + repeat(dataSize) { + add(6f) + } + }, + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun IndicatedBitrateEmptyPreview() { + PillarboxTheme { + Column { + IndicatedBitrate( + bitRates = BitRates(data = emptyList()), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ObservedBitratePreview() { + val dataSize = 10 + val minValue = 60f + val maxValue = 120f + + PillarboxTheme { + Column { + ObservedBitrate( + bitRates = BitRates( + data = buildList(dataSize) { + repeat(dataSize) { + add(minValue + Random.nextFloat() * (maxValue - minValue)) + } + }, + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ObservedBitrateEmptyPreview() { + PillarboxTheme { + Column { + ObservedBitrate( + bitRates = BitRates(data = emptyList()), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun DataVolumePreview() { + val dataSize = 10 + val minValue = 0f + val maxValue = 50f + val data = buildList(dataSize) { + repeat(dataSize) { + add(minValue + Random.nextFloat() * (maxValue - minValue)) + } + } + + PillarboxTheme { + Column { + DataVolume( + volumes = DataVolumes( + data = data, + total = data.sum().toString(), + ) + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun DataVolumeEmptyPreview() { + PillarboxTheme { + Column { + DataVolume( + volumes = DataVolumes(emptyList(), ""), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun StallsPreview() { + val dataSize = 10 + val minValue = 0 + val maxValue = 5 + val data = buildList(dataSize) { + repeat(dataSize) { + add(Random.nextInt(minValue, maxValue).toFloat()) + } + } + + PillarboxTheme { + Column { + Stalls( + stalls = Stalls( + data = data, + total = data.sum().toString(), + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun StallsEmptyPreview() { + PillarboxTheme { + Column { + Stalls( + stalls = Stalls(emptyList(), ""), + ) + } + } +} diff --git a/pillarbox-demo/build.gradle.kts b/pillarbox-demo/build.gradle.kts index 81c20798e..d672246e3 100644 --- a/pillarbox-demo/build.gradle.kts +++ b/pillarbox-demo/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.window.size) implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.geometry) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/components/DemoListItemView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/components/DemoListItemView.kt index 1a71d1cd7..f4b2c3735 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/components/DemoListItemView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/components/DemoListItemView.kt @@ -4,15 +4,23 @@ */ package ch.srgssr.pillarbox.demo.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme @@ -62,9 +70,57 @@ fun DemoListItemView( } } +/** + * Demo item view. + * + * @param leadingText The leading text of the item. + * @param trailingText The trailing text of the item. + * @param modifier The [Modifier] to apply to the root of the item. + */ +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun DemoListItemView( + leadingText: String, + trailingText: String, + modifier: Modifier = Modifier, +) { + val clipboardManager = LocalClipboardManager.current + + Row( + modifier = modifier + .combinedClickable( + onLongClick = { clipboardManager.setText(AnnotatedString(trailingText)) }, + onClick = {}, + ) + .padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = leadingText, + modifier = Modifier + .padding(end = MaterialTheme.paddings.baseline) + .align(Alignment.Top), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = trailingText, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.End, + style = MaterialTheme.typography.bodySmall, + ) + } +} + @Composable @Preview(showBackground = true) -private fun DemoItemPreview() { +private fun DemoItemTitleSubtitlePreview() { PillarboxTheme { Column { val itemModifier = Modifier.fillMaxWidth() @@ -84,3 +140,15 @@ private fun DemoItemPreview() { } } } + +@Composable +@Preview(showBackground = true) +private fun DemoItemLeadingTrailingPreview() { + PillarboxTheme { + DemoListItemView( + leadingText = "Title 1", + trailingText = "Description 1", + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt index 34854abfe..1e7abc921 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt @@ -4,12 +4,24 @@ */ package ch.srgssr.pillarbox.demo.ui.player +import android.app.Activity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,10 +56,10 @@ import com.google.accompanist.navigation.material.rememberBottomSheetNavigator * @param player The [Player] to observe. * @param modifier The modifier to be applied to the layout. * @param pictureInPicture The picture in picture state. - * @param pictureInPictureClick he picture in picture button action. If null no button. + * @param pictureInPictureClick The picture in picture button action. If null no button. * @param displayPlaylist If it displays playlist ui or not. */ -@OptIn(ExperimentalMaterialNavigationApi::class) +@OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun DemoPlayerView( player: Player, @@ -56,37 +68,72 @@ fun DemoPlayerView( pictureInPictureClick: (() -> Unit)? = null, displayPlaylist: Boolean = false, ) { - val bottomSheetNavigator = rememberBottomSheetNavigator() - val navController = rememberNavController(bottomSheetNavigator) - LaunchedEffect(bottomSheetNavigator.navigatorSheetState.isVisible) { - if (!bottomSheetNavigator.navigatorSheetState.isVisible) { - navController.popBackStack(route = RoutePlayer, false) + val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity) + val useSidePanel = windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium + + if (useSidePanel) { + var showSettings by remember { mutableStateOf(false) } + + Row(modifier = modifier.displayCutoutPadding()) { + PlayerContent( + player = player, + modifier = Modifier + .animateContentSize() + .then(if (showSettings) Modifier.weight(0.66f) else Modifier), + pictureInPicture = pictureInPicture, + pictureInPictureClick = pictureInPictureClick, + displayPlaylist = displayPlaylist, + ) { + showSettings = !showSettings + } + + AnimatedVisibility( + visible = showSettings, + modifier = if (showSettings) Modifier.weight(0.33f) else Modifier, + enter = fadeIn() + slideInHorizontally { it }, + exit = fadeOut() + slideOutHorizontally { it }, + ) { + PlaybackSettingsContent(player = player) + } } - } - ModalBottomSheetLayout( - modifier = modifier, - bottomSheetNavigator = bottomSheetNavigator - ) { - NavHost(navController, startDestination = RoutePlayer) { - composable(route = "player") { - PlayerContent( - player = player, - pictureInPicture = pictureInPicture, - pictureInPictureClick = pictureInPictureClick, - displayPlaylist = displayPlaylist, - ) { - navController.navigate(route = RouteSettings) { - launchSingleTop = true + } else { + val bottomSheetNavigator = rememberBottomSheetNavigator() + val navController = rememberNavController(bottomSheetNavigator) + + LaunchedEffect(bottomSheetNavigator.navigatorSheetState.isVisible) { + if (!bottomSheetNavigator.navigatorSheetState.isVisible) { + navController.popBackStack(route = RoutePlayer, false) + } + } + + ModalBottomSheetLayout( + modifier = modifier, + bottomSheetNavigator = bottomSheetNavigator, + ) { + NavHost(navController, startDestination = RoutePlayer) { + composable(route = RoutePlayer) { + PlayerContent( + player = player, + modifier = Modifier.fillMaxSize(), + pictureInPicture = pictureInPicture, + pictureInPictureClick = pictureInPictureClick, + displayPlaylist = displayPlaylist, + ) { + navController.navigate(route = RouteSettings) { + launchSingleTop = true + } } } - } - bottomSheet(route = RouteSettings) { - LaunchedEffect(pictureInPicture) { - if (pictureInPicture) { - navController.popBackStack() + + bottomSheet(route = RouteSettings) { + LaunchedEffect(pictureInPicture) { + if (pictureInPicture) { + navController.popBackStack() + } } + + PlaybackSettingsContent(player = player) } - PlaybackSettingsContent(player = player) } } } @@ -95,6 +142,7 @@ fun DemoPlayerView( @Composable private fun PlayerContent( player: Player, + modifier: Modifier = Modifier, pictureInPicture: Boolean = false, pictureInPictureClick: (() -> Unit)? = null, displayPlaylist: Boolean = false, @@ -112,7 +160,7 @@ private fun PlayerContent( } val appSettings by appSettingsRepository.getAppSettings().collectAsStateWithLifecycle(AppSettings()) ShowSystemUi(isShowed = !fullScreenState) - Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = modifier) { var pinchScaleMode by remember(fullScreenState) { mutableStateOf(ScaleMode.Fit) } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/StatsForNerds.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/StatsForNerds.kt new file mode 100644 index 000000000..644aa36f2 --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/StatsForNerds.kt @@ -0,0 +1,550 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.ui.player.metrics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.compose.viewModel +import ch.srgssr.pillarbox.demo.shared.R +import ch.srgssr.pillarbox.demo.shared.ui.components.BarChart +import ch.srgssr.pillarbox.demo.shared.ui.components.LineChart +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.BitRates +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.DataVolumes +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.Stalls +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.StatsForNerdsViewModel +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.StatsForNerdsViewModel.Companion.CHART_ASPECT_RATIO +import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.StatsForNerdsViewModel.Companion.CHART_MAX_POINTS +import ch.srgssr.pillarbox.demo.ui.components.DemoListHeaderView +import ch.srgssr.pillarbox.demo.ui.components.DemoListItemView +import ch.srgssr.pillarbox.demo.ui.components.DemoListSectionView +import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme +import ch.srgssr.pillarbox.demo.ui.theme.paddings +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun StatsForNerds( + playbackMetrics: PlaybackMetrics, + modifier: Modifier = Modifier, +) { + val statsForNerdsViewModel = viewModel(key = playbackMetrics.sessionId) + val startupTimes by statsForNerdsViewModel.startupTimes.collectAsState() + val information by statsForNerdsViewModel.information.collectAsState() + val indicatedBitRates by statsForNerdsViewModel.indicatedBitRates.collectAsState() + val observedBitRates by statsForNerdsViewModel.observedBitRates.collectAsState() + val volumes by statsForNerdsViewModel.volumes.collectAsState() + val stalls by statsForNerdsViewModel.stalls.collectAsState() + + LaunchedEffect(playbackMetrics) { + statsForNerdsViewModel.playbackMetrics = playbackMetrics + } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .then(modifier), + ) { + Text( + text = stringResource(R.string.stats_for_nerds), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.titleLarge, + ) + + StartupTimes(startupTimes) + + Information(information) + + IndicatedBitrate(indicatedBitRates) + + ObservedBitrate(observedBitRates) + + DataVolume(volumes) + + Stalls(stalls) + } +} + +@Composable +private fun StartupTimes( + loadDurations: Map, +) { + if (loadDurations.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.startup_times), + ) { + var index = 0 + loadDurations.forEach { (label, duration) -> + DemoListItemView( + leadingText = label, + trailingText = duration, + modifier = Modifier.fillMaxWidth(), + ) + + if (index < loadDurations.size - 1) { + HorizontalDivider() + } + + index++ + } + } +} + +@Composable +private fun Information( + information: Map, +) { + if (information.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.media_information), + ) { + var index = 0 + information.forEach { (label, value) -> + DemoListItemView( + leadingText = label, + trailingText = value, + modifier = Modifier.fillMaxWidth(), + ) + + if (index < information.size - 1) { + HorizontalDivider() + } + + index++ + } + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun IndicatedBitrate( + bitRates: BitRates, +) { + if (bitRates.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.indicated_bitrate), + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text( + text = bitRates.unit, + modifier = Modifier + .align(Alignment.End) + .padding(vertical = MaterialTheme.paddings.small) + .padding(end = MaterialTheme.paddings.small), + ) + + LineChart( + data = bitRates.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = stringResource(R.string.minimum_value, bitRates.min)) + + Text(text = stringResource(R.string.current_value, bitRates.current)) + + Text(text = stringResource(R.string.maximum_value, bitRates.max)) + } + } + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun ObservedBitrate( + bitRates: BitRates, +) { + if (bitRates.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.observed_bitrate), + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text( + text = bitRates.unit, + modifier = Modifier + .align(Alignment.End) + .padding(vertical = MaterialTheme.paddings.small) + .padding(end = MaterialTheme.paddings.small), + ) + + LineChart( + data = bitRates.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + lineColor = Color.Blue, + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = stringResource(R.string.minimum_value, bitRates.min)) + + Text(text = stringResource(R.string.current_value, bitRates.current)) + + Text(text = stringResource(R.string.maximum_value, bitRates.max)) + } + } + } +} + +@Composable +private fun DataVolume( + volumes: DataVolumes, +) { + if (volumes.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.data_volume), + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text( + text = volumes.unit, + modifier = Modifier + .align(Alignment.End) + .padding(vertical = MaterialTheme.paddings.small) + .padding(end = MaterialTheme.paddings.small), + ) + + BarChart( + data = volumes.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + barColor = Color.Cyan, + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + Text( + text = stringResource(R.string.total_volume, volumes.total), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + ) + } + } +} + +@Composable +private fun Stalls( + stalls: Stalls, +) { + if (stalls.data.isEmpty()) { + return + } + + Section( + title = stringResource(R.string.stalls), + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + LineChart( + data = stalls.data, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(CHART_ASPECT_RATIO), + lineColor = Color.Yellow, + stretchChartToPointsCount = CHART_MAX_POINTS, + scaleTextStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + + Text( + text = stringResource(R.string.total_stalls, stalls.total), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small, + ), + ) + } + } +} + +@Composable +private fun Section( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + DemoListHeaderView( + title = title, + modifier = Modifier.padding(start = MaterialTheme.paddings.baseline), + ) + + DemoListSectionView { + content() + } +} + +@Composable +@Preview(showBackground = true) +private fun StartupTimesPreview() { + val asset = 123.milliseconds + val manifest = 456.milliseconds + val drm = 789.milliseconds + val source = 987.milliseconds + + PillarboxTheme { + Column { + StartupTimes( + loadDurations = mapOf( + stringResource(R.string.asset_loading) to asset.toString(), + stringResource(R.string.manifest_loading) to manifest.toString(), + stringResource(R.string.drm_loading) to drm.toString(), + stringResource(R.string.resource_loading) to source.toString(), + stringResource(R.string.total_load_time) to (asset + manifest + drm + source).toString(), + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun StartupTimesEmptyPreview() { + PillarboxTheme { + Column { + StartupTimes(loadDurations = emptyMap()) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InformationPreview() { + PillarboxTheme { + Column { + Information( + information = mapOf( + stringResource(R.string.session_id) to "abcdef-123456-ghijkl", + stringResource(R.string.media_uri) to "https://www.google.com/", + stringResource(R.string.playback_duration) to 42.seconds.toString(), + stringResource(R.string.data_volume) to "123.456 MB", + stringResource(R.string.buffering) to 3.seconds.toString(), + stringResource(R.string.video_size) to "1280x720", + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun InformationEmptyPreview() { + PillarboxTheme { + Column { + Information( + information = emptyMap(), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun IndicatedBitratePreview() { + val dataSize = 10 + + PillarboxTheme { + Column { + IndicatedBitrate( + bitRates = BitRates( + data = buildList(dataSize) { + repeat(dataSize) { + add(6f) + } + }, + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun IndicatedBitrateEmptyPreview() { + PillarboxTheme { + Column { + IndicatedBitrate( + bitRates = BitRates(data = emptyList()), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ObservedBitratePreview() { + val dataSize = 10 + val minValue = 60f + val maxValue = 120f + + PillarboxTheme { + Column { + ObservedBitrate( + bitRates = BitRates( + data = buildList(dataSize) { + repeat(dataSize) { + add(minValue + Random.nextFloat() * (maxValue - minValue)) + } + }, + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ObservedBitrateEmptyPreview() { + PillarboxTheme { + Column { + ObservedBitrate( + bitRates = BitRates(data = emptyList()), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun DataVolumePreview() { + val dataSize = 10 + val minValue = 0f + val maxValue = 50f + val data = buildList(dataSize) { + repeat(dataSize) { + add(minValue + Random.nextFloat() * (maxValue - minValue)) + } + } + + PillarboxTheme { + Column { + DataVolume( + volumes = DataVolumes( + data = data, + total = data.sum().toString(), + ) + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun DataVolumeEmptyPreview() { + PillarboxTheme { + Column { + DataVolume( + volumes = DataVolumes(emptyList(), ""), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun StallsPreview() { + val dataSize = 10 + val minValue = 0 + val maxValue = 5 + val data = buildList(dataSize) { + repeat(dataSize) { + add(Random.nextInt(minValue, maxValue).toFloat()) + } + } + + PillarboxTheme { + Column { + Stalls( + stalls = Stalls( + data = data, + total = data.sum().toString(), + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun StallsEmptyPreview() { + PillarboxTheme { + Column { + Stalls( + stalls = Stalls(emptyList(), ""), + ) + } + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt index 685607588..0e033d25a 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt @@ -7,19 +7,23 @@ package ch.srgssr.pillarbox.demo.ui.player.settings import android.app.Application import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.Player import androidx.navigation.compose.NavHost @@ -28,18 +32,28 @@ import androidx.navigation.compose.rememberNavController import ch.srgssr.pillarbox.demo.shared.ui.player.settings.PlayerSettingsViewModel import ch.srgssr.pillarbox.demo.shared.ui.player.settings.SettingItem import ch.srgssr.pillarbox.demo.shared.ui.player.settings.SettingsRoutes +import ch.srgssr.pillarbox.demo.ui.player.metrics.StatsForNerds +import ch.srgssr.pillarbox.demo.ui.theme.paddings +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.currentPositionAsFlow +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.seconds /** * Playback settings content * * @param player The [Player] actions occurred. + * @param modifier The [Modifier] to apply to the layout. */ @Composable -fun PlaybackSettingsContent(player: Player) { +fun PlaybackSettingsContent( + player: Player, + modifier: Modifier = Modifier, +) { val application = LocalContext.current.applicationContext as Application val navController = rememberNavController() val settingsViewModel: PlayerSettingsViewModel = viewModel(factory = PlayerSettingsViewModel.Factory(player, application)) - Surface { + Surface(modifier = modifier) { NavHost(navController = navController, startDestination = SettingsRoutes.Main.route) { composable( route = SettingsRoutes.Main.route, @@ -136,6 +150,32 @@ fun PlaybackSettingsContent(player: Player) { ) } } + + composable( + route = SettingsRoutes.StatsForNerds.route, + exitTransition = { + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Down) + }, + enterTransition = { + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Up) + }, + ) { + if (player !is PillarboxExoPlayer) { + return@composable + } + + val playbackMetrics by remember(player) { + player.currentPositionAsFlow(updateInterval = 1.seconds) + .map { player.getCurrentMetrics() } + }.collectAsStateWithLifecycle(player.getCurrentMetrics()) + + playbackMetrics?.let { + StatsForNerds( + playbackMetrics = it, + modifier = Modifier.padding(MaterialTheme.paddings.baseline), + ) + } + } } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt index c8a5afdaf..1d002a3b7 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt @@ -53,6 +53,7 @@ import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettings import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsRepository import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsViewModel import ch.srgssr.pillarbox.demo.ui.components.DemoListHeaderView +import ch.srgssr.pillarbox.demo.ui.components.DemoListItemView import ch.srgssr.pillarbox.demo.ui.components.DemoListSectionView import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.ui.theme.paddings @@ -131,16 +132,18 @@ private fun MetricsOverlaySettings( @Composable private fun LibraryVersionSection() { SettingSection(title = stringResource(R.string.settings_library_version)) { - TextLabel( - text = "Pillarbox: ${BuildConfig.VERSION_NAME}", - modifier = Modifier.padding(vertical = MaterialTheme.paddings.small), + DemoListItemView( + leadingText = "Pillarbox", + trailingText = BuildConfig.VERSION_NAME, + modifier = Modifier.fillMaxWidth(), ) HorizontalDivider() - TextLabel( - text = "Media3: ${MediaLibraryInfo.VERSION}", - modifier = Modifier.padding(vertical = MaterialTheme.paddings.small), + DemoListItemView( + leadingText = "Media3", + trailingText = MediaLibraryInfo.VERSION, + modifier = Modifier.fillMaxWidth(), ) } }