diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 366b0881c..43bc10264 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ androidx-activity = "1.9.1" androidx-annotation = "1.8.2" androidx-compose = "2024.06.00" androidx-core = "1.13.1" +androidx-datastore = "1.1.1" androidx-fragment = "1.8.2" androidx-lifecycle = "2.8.4" androidx-media3 = "1.4.0" @@ -44,6 +45,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } androidx-fragment = { module = "androidx.fragment:fragment", version.ref = "androidx-fragment" } androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" } diff --git a/pillarbox-demo-shared/build.gradle.kts b/pillarbox-demo-shared/build.gradle.kts index 422fb9a2d..c70f8ae58 100644 --- a/pillarbox-demo-shared/build.gradle.kts +++ b/pillarbox-demo-shared/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { api(libs.androidx.compose.ui.text) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.unit) + api(libs.androidx.datastore.preferences) api(libs.androidx.lifecycle.viewmodel) api(libs.androidx.media3.common) implementation(libs.androidx.media3.exoplayer) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt index d3f1db2e0..d2438a0ed 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination @@ -46,6 +47,11 @@ sealed class HomeDestination( * Info home page */ data object Search : HomeDestination(NavigationRoutes.searchHome, R.string.search, Icons.Default.Search) + + /** + * Settings home page + */ + data object Settings : HomeDestination(NavigationRoutes.settingsHome, R.string.settings, Icons.Default.Settings) } /** diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt index e2d656937..e3455a681 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt @@ -30,4 +30,5 @@ object NavigationRoutes { const val contentLists = "content_lists" const val contentList = "content_list" const val searchHome = "search_home" + const val settingsHome = "settings_home" } diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettings.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettings.kt new file mode 100644 index 000000000..6e5fd3ea9 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettings.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * App settings + * + * @property metricsOverlayEnabled + * @property metricsOverlayTextSize + * @property metricsOverlayTextColor + */ +class AppSettings( + val metricsOverlayEnabled: Boolean = false, + val metricsOverlayTextSize: TextSize = TextSize.Medium, + val metricsOverlayTextColor: TextColor = TextColor.Yellow, +) { + + /** + * Text size + * + * @property size the [TextUnit]. + */ + enum class TextSize(val size: TextUnit) { + Small(8.sp), + Medium(12.sp), + Large(18.sp), + } + + /** + * Text color + * + * @property color the [Color]. + */ + enum class TextColor(val color: Color) { + Yellow(Color.Yellow), + Red(Color.Red), + Green(Color.Green), + Blue(Color.Blue), + White(Color.White) + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsRepository.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsRepository.kt new file mode 100644 index 000000000..88b95f0ba --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsRepository.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettings.TextColor +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettings.TextSize +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +private val Context.dataStore by preferencesDataStore(name = "settings") + +/** + * App settings repository + * @param context The context. + */ +class AppSettingsRepository(context: Context) { + private val dataStore = context.dataStore + + /** + * Get app settings + * + * @return + */ + fun getAppSettings(): Flow { + return dataStore.data + .catch { + if (it is IOException) { + emit(emptyPreferences()) + } else { + throw it + } + } + .map { preferences -> + AppSettings( + metricsOverlayTextSize = preferences.getEnum(PreferencesKeys.METRICS_OVERLAY_TEXT_SIZE, TextSize.Medium), + metricsOverlayTextColor = preferences.getEnum(PreferencesKeys.METRICS_OVERLAY_TEXT_COLOR, TextColor.Yellow), + metricsOverlayEnabled = preferences[PreferencesKeys.METRICS_OVERLAY_ENABLED] ?: false, + ) + } + } + + /** + * Set metrics overlay enabled + * + * @param enabled + */ + suspend fun setMetricsOverlayEnabled(enabled: Boolean) { + dataStore.edit { + it[PreferencesKeys.METRICS_OVERLAY_ENABLED] = enabled + } + } + + /** + * Set metrics overlay text color + * + * @param textColor + */ + suspend fun setMetricsOverlayTextColor(textColor: TextColor) { + dataStore.edit { + it[PreferencesKeys.METRICS_OVERLAY_TEXT_COLOR] = textColor.name + } + } + + /** + * Set metrics overlay text size + * + * @param textSize + */ + suspend fun setMetricsOverlayTextSize(textSize: TextSize) { + dataStore.edit { + it[PreferencesKeys.METRICS_OVERLAY_TEXT_SIZE] = textSize.name + } + } + + private object PreferencesKeys { + val METRICS_OVERLAY_ENABLED = booleanPreferencesKey("metrics_overlay_enabled") + val METRICS_OVERLAY_TEXT_COLOR = stringPreferencesKey("metrics_overlay_text_color") + val METRICS_OVERLAY_TEXT_SIZE = stringPreferencesKey("metrics_overlay_text_size") + } + + private companion object { + private const val TAG = "AppSettingsRepository" + + private inline fun > Preferences.getEnum( + key: Preferences.Key, + defaultValue: T, + ): T { + return try { + get(key)?.let { enumValueOf(it) } ?: defaultValue + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Can't parse enum value", e) + defaultValue + } + } + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsViewModel.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsViewModel.kt new file mode 100644 index 000000000..f72bb9be0 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +/** + * App settings view model + * + * @param appSettingsRepository + */ +class AppSettingsViewModel(private val appSettingsRepository: AppSettingsRepository) : ViewModel() { + + /** + * Current app settings + */ + val currentAppSettings = appSettingsRepository.getAppSettings() + + /** + * Set metrics overlay enabled + * + * @param enabled + */ + fun setMetricsOverlayEnabled(enabled: Boolean) { + viewModelScope.launch { + appSettingsRepository.setMetricsOverlayEnabled(enabled) + } + } + + /** + * Set metrics overlay text color + * + * @param textColor + */ + fun setMetricsOverlayTextColor(textColor: AppSettings.TextColor) { + viewModelScope.launch { + appSettingsRepository.setMetricsOverlayTextColor(textColor) + } + } + + /** + * Set metrics overlay text size + * + * @param textSize + */ + fun setMetricsOverlayTextSize(textSize: AppSettings.TextSize) { + viewModelScope.launch { + appSettingsRepository.setMetricsOverlayTextSize(textSize) + } + } + + /** + * Factory + * + * @param appSettingsRepository + */ + class Factory(private val appSettingsRepository: AppSettingsRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return AppSettingsViewModel(appSettingsRepository) as T + } + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/MetricsOverlayOptions.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/MetricsOverlayOptions.kt new file mode 100644 index 000000000..9073e9f91 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/MetricsOverlayOptions.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.TextUnit + +/** + * Metrics overlay options + * + * @property textColor The [Color] for the text overlay. + * @property textSize The [TextUnit] for the text overlay. + */ + +data class MetricsOverlayOptions( + val textColor: Color = Color.Yellow, + val textSize: TextUnit = TextUnit.Unspecified, +) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt index 7b107ee22..c2bedf8ba 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt @@ -59,22 +59,30 @@ import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination import ch.srgssr.pillarbox.demo.shared.ui.NavigationRoutes import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel import ch.srgssr.pillarbox.demo.shared.ui.navigate +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsRepository +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsViewModel import ch.srgssr.pillarbox.demo.ui.examples.ExamplesHome import ch.srgssr.pillarbox.demo.ui.lists.listsNavGraph import ch.srgssr.pillarbox.demo.ui.player.SimplePlayerActivity import ch.srgssr.pillarbox.demo.ui.search.SearchHome +import ch.srgssr.pillarbox.demo.ui.settings.AppSettingsView import ch.srgssr.pillarbox.demo.ui.showcases.showcasesNavGraph import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.ui.theme.paddings import java.net.URL -private val bottomNavItems = listOf(HomeDestination.Examples, HomeDestination.ShowCases, HomeDestination.Lists, HomeDestination.Search) +private val bottomNavItems = + listOf(HomeDestination.Examples, HomeDestination.ShowCases, HomeDestination.Lists, HomeDestination.Search, HomeDestination.Settings) private val topLevelRoutes = - listOf(HomeDestination.Examples.route, NavigationRoutes.showcaseList, NavigationRoutes.contentLists, HomeDestination.Search.route) + listOf( + HomeDestination.Examples.route, NavigationRoutes.showcaseList, NavigationRoutes.contentLists, HomeDestination.Search.route, + HomeDestination.Settings.route + ) /** * Main view with all the navigation */ +@Suppress("StringLiteralDuplication") @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainNavigation() { @@ -135,6 +143,15 @@ fun MainNavigation() { listsNavGraph(navController, ilRepository, ilHost) } + composable(route = HomeDestination.Settings.route, DemoPageView("home", listOf("app", "pillarbox", "settings"))) { + val appSettingsRepository = remember(context) { + AppSettingsRepository(context) + } + + val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = AppSettingsViewModel.Factory(appSettingsRepository)) + AppSettingsView(appSettingsViewModel) + } + composable(route = NavigationRoutes.searchHome, DemoPageView("home", listOf("app", "pillarbox", "search"))) { val ilRepository = PlayerModule.createIlRepository(context) val viewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory(ilRepository)) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt index 32bc1eb5f..323f97842 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt @@ -13,16 +13,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel -import ch.srgssr.pillarbox.demo.BuildConfig import ch.srgssr.pillarbox.demo.shared.data.DemoItem import ch.srgssr.pillarbox.demo.shared.data.Playlist import ch.srgssr.pillarbox.demo.shared.ui.examples.ExamplesViewModel @@ -55,10 +52,7 @@ private fun ListStreamView( onItemClicked: (item: DemoItem) -> Unit ) { LazyColumn( - contentPadding = PaddingValues( - horizontal = MaterialTheme.paddings.baseline, - vertical = MaterialTheme.paddings.small - ), + contentPadding = PaddingValues(MaterialTheme.paddings.baseline), verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small), ) { item(contentType = "url_urn_input") { @@ -96,15 +90,6 @@ private fun ListStreamView( } } } - - item(contentType = "app_version") { - Text( - text = BuildConfig.VERSION_NAME, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium - ) - } } } 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 aadbdcbee..34854abfe 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 @@ -18,10 +18,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +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.MetricsOverlayOptions import ch.srgssr.pillarbox.demo.ui.components.ShowSystemUi import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerBottomToolbar import ch.srgssr.pillarbox.demo.ui.player.playlist.PlaylistView @@ -101,6 +106,11 @@ private fun PlayerContent( val fullScreenToggle: (Boolean) -> Unit = { fullScreenEnabled -> fullScreenState = fullScreenEnabled } + val context = LocalContext.current + val appSettingsRepository = remember { + AppSettingsRepository(context) + } + val appSettings by appSettingsRepository.getAppSettings().collectAsStateWithLifecycle(AppSettings()) ShowSystemUi(isShowed = !fullScreenState) Column(modifier = Modifier.fillMaxSize()) { var pinchScaleMode by remember(fullScreenState) { @@ -127,7 +137,12 @@ private fun PlayerContent( player = player, controlsToggleable = !pictureInPicture, controlsVisible = !pictureInPicture, - scaleMode = pinchScaleMode + scaleMode = pinchScaleMode, + overlayEnabled = appSettings.metricsOverlayEnabled, + overlayOptions = MetricsOverlayOptions( + textColor = appSettings.metricsOverlayTextColor.color, + textSize = appSettings.metricsOverlayTextSize.size + ), ) { PlayerBottomToolbar( modifier = Modifier.fillMaxWidth(), diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt index e9eb2b610..7917e1b2c 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt @@ -20,13 +20,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player +import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerControls import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerError import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerNoContent import ch.srgssr.pillarbox.demo.ui.player.controls.SkipButton import ch.srgssr.pillarbox.demo.ui.player.controls.rememberProgressTrackerState +import ch.srgssr.pillarbox.demo.ui.player.metrics.MetricsOverlay import ch.srgssr.pillarbox.demo.ui.theme.paddings +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.currentPositionAsFlow import ch.srgssr.pillarbox.ui.ProgressTrackerState import ch.srgssr.pillarbox.ui.ScaleMode import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView @@ -38,6 +43,8 @@ import ch.srgssr.pillarbox.ui.widget.ToggleableBox import ch.srgssr.pillarbox.ui.widget.keepScreenOn import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface import ch.srgssr.pillarbox.ui.widget.rememberDelayedVisibilityState +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.milliseconds /** * Simple player view @@ -48,6 +55,8 @@ import ch.srgssr.pillarbox.ui.widget.rememberDelayedVisibilityState * @param controlsVisible The control visibility. * @param controlsToggleable The controls are toggleable. * @param progressTracker The progress tracker. + * @param overlayOptions The [MetricsOverlayOptions]. + * @param overlayEnabled true to display the metrics overlay. * @param content The action to display under the slider. */ @Composable @@ -58,6 +67,8 @@ fun PlayerView( controlsVisible: Boolean = true, controlsToggleable: Boolean = true, progressTracker: ProgressTrackerState = rememberProgressTrackerState(player = player, smoothTracker = true), + overlayOptions: MetricsOverlayOptions = MetricsOverlayOptions(), + overlayEnabled: Boolean = false, content: @Composable ColumnScope.() -> Unit = {}, ) { val playerError by player.playerErrorAsState() @@ -116,6 +127,23 @@ fun PlayerView( } } ExoPlayerSubtitleView(player = player) + if (overlayEnabled && player is PillarboxExoPlayer) { + val currentMetricsFlow = remember(player) { + player.currentPositionAsFlow(updateInterval = 500.milliseconds).map { + player.getCurrentMetrics() + } + } + val currentMetrics by currentMetricsFlow.collectAsStateWithLifecycle(player.getCurrentMetrics()) + currentMetrics?.let { + MetricsOverlay( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopStart), + playbackMetrics = it, + overlayOptions = overlayOptions, + ) + } + } } if (currentCredit != null && !visibilityState.isVisible) { diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/MetricsOverlay.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/MetricsOverlay.kt new file mode 100644 index 000000000..857ed2d17 --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/MetricsOverlay.kt @@ -0,0 +1,130 @@ +/* + * 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.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import androidx.media3.common.Format +import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.utils.BitrateUtil.toByteRate + +/** + * Display [playbackMetrics] as overlay. + * + * @param playbackMetrics The [PlaybackMetrics] to display. + * @param overlayOptions The [MetricsOverlayOptions] the options. + * @param modifier The modifier to be applied to the layout. + */ +@Composable +fun MetricsOverlay( + playbackMetrics: PlaybackMetrics, + overlayOptions: MetricsOverlayOptions, + modifier: Modifier = Modifier, +) { + val currentVideoFormat = playbackMetrics.videoFormat + val currentAudioFormat = playbackMetrics.audioFormat + Column(modifier = modifier) { + currentVideoFormat?.let { + OverlayText( + overlayOptions = overlayOptions, + text = "video format codecs:${it.codecs} ${it.bitrate.toByteRate()}Bps frame-rate:${it.frameRate}" + ) + } + currentAudioFormat?.let { + OverlayText( + overlayOptions = overlayOptions, + text = "audio format codes:${it.codecs} ${it.bitrate.toByteRate()}Bps channels=${it.channelCount} sample-rate:${it.sampleRate}Hz" + ) + } + + val averageBitRateString = StringBuilder("average bitrate ") + currentVideoFormat?.getAverageBitrateOrNull()?.let { + averageBitRateString.append("video:${it.toByteRate()}Bps ") + } + currentAudioFormat?.getAverageBitrateOrNull()?.let { + averageBitRateString.append("audio:${it.toByteRate()}Bps") + } + OverlayText(text = averageBitRateString.toString(), overlayOptions = overlayOptions) + + val peekBitrateString = StringBuilder("peek bitrate ") + currentVideoFormat?.getPeekBitrateOrNull()?.let { + peekBitrateString.append("video:${it.toByteRate()}Bps ") + } + currentAudioFormat?.getPeekBitrateOrNull()?.let { + peekBitrateString.append("audio:${it.toByteRate()}Bps") + } + OverlayText(text = peekBitrateString.toString(), overlayOptions = overlayOptions) + + OverlayText( + overlayOptions = overlayOptions, + text = "indicated bitrate: ${playbackMetrics.indicatedBitrate.toByteRate()}Bps" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "bandwidth ${playbackMetrics.bandwidth.toByteRate()}Bps" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "asset: ${playbackMetrics.loadDuration.asset}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "drm: ${playbackMetrics.loadDuration.drm}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "manifest: ${playbackMetrics.loadDuration.manifest}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "source: ${playbackMetrics.loadDuration.source}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "timeToReady: ${playbackMetrics.loadDuration.timeToReady}" + ) + + OverlayText( + overlayOptions = overlayOptions, + text = "playtime: ${playbackMetrics.playbackDuration}" + ) + } +} + +@Composable +private fun OverlayText( + text: String, + overlayOptions: MetricsOverlayOptions, + modifier: Modifier = Modifier +) { + BasicText( + modifier = modifier, + style = TextStyle.Default.copy(fontSize = overlayOptions.textSize), + color = { overlayOptions.textColor }, + text = text, + ) +} + +@Preview +@Composable +private fun OverlayTextPreview() { + val overlayOptions = MetricsOverlayOptions(textColor = Color.Yellow, textSize = 12.sp) + OverlayText(text = "Text; 12 ac1.mp3 channels:4 colors:4", overlayOptions = overlayOptions) +} + +private fun Format.getPeekBitrateOrNull(): Int? { + return if (peakBitrate == Format.NO_VALUE) null else peakBitrate +} + +private fun Format.getAverageBitrateOrNull(): Int? { + return if (averageBitrate == Format.NO_VALUE) null else averageBitrate +} 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 new file mode 100644 index 000000000..c8a5afdaf --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt @@ -0,0 +1,300 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.MediaLibraryInfo +import ch.srgssr.pillarbox.demo.BuildConfig +import ch.srgssr.pillarbox.demo.R +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.DemoListSectionView +import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme +import ch.srgssr.pillarbox.demo.ui.theme.paddings + +/** + * App settings view + * + * @param settingsViewModel The [AppSettingsViewModel] + * @param modifier The [Modifier] to apply to the layout. + */ +@Composable +fun AppSettingsView( + settingsViewModel: AppSettingsViewModel, + modifier: Modifier = Modifier, +) { + val appSettings by settingsViewModel.currentAppSettings.collectAsStateWithLifecycle(AppSettings()) + + Column( + modifier = modifier + .padding(horizontal = MaterialTheme.paddings.baseline) + .padding(bottom = MaterialTheme.paddings.baseline) + .verticalScroll(rememberScrollState()), + ) { + MetricsOverlaySettings( + appSettings = appSettings, + setMetricsOverlayTextSize = settingsViewModel::setMetricsOverlayTextSize, + setMetricsOverlayEnabled = settingsViewModel::setMetricsOverlayEnabled, + setMetricsOverlayTextColor = settingsViewModel::setMetricsOverlayTextColor, + ) + + LibraryVersionSection() + } +} + +@Composable +private fun MetricsOverlaySettings( + appSettings: AppSettings, + setMetricsOverlayEnabled: (Boolean) -> Unit, + setMetricsOverlayTextColor: (AppSettings.TextColor) -> Unit, + setMetricsOverlayTextSize: (AppSettings.TextSize) -> Unit, +) { + SettingSection(title = stringResource(R.string.setting_metrics_overlay)) { + TextLabel(text = stringResource(R.string.settings_enabled_overlay_description)) + + LabeledSwitch( + text = stringResource(R.string.settings_enabled_metrics_overlay), + checked = appSettings.metricsOverlayEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(top = MaterialTheme.paddings.small), + onCheckedChange = setMetricsOverlayEnabled, + ) + + AnimatedVisibility(visible = appSettings.metricsOverlayEnabled) { + Column { + DropdownSetting( + text = stringResource(R.string.settings_choose_text_color), + entries = AppSettings.TextColor.entries, + selectedEntry = appSettings.metricsOverlayTextColor, + modifier = Modifier.fillMaxWidth(), + onEntrySelected = setMetricsOverlayTextColor, + ) + + DropdownSetting( + text = stringResource(R.string.settings_choose_text_size), + entries = AppSettings.TextSize.entries, + selectedEntry = appSettings.metricsOverlayTextSize, + modifier = Modifier.fillMaxWidth(), + onEntrySelected = setMetricsOverlayTextSize, + ) + } + } + } +} + +@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), + ) + + HorizontalDivider() + + TextLabel( + text = "Media3: ${MediaLibraryInfo.VERSION}", + modifier = Modifier.padding(vertical = MaterialTheme.paddings.small), + ) + } +} + +@Composable +private fun SettingSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + DemoListHeaderView( + title = title, + modifier = Modifier.padding(start = MaterialTheme.paddings.baseline) + ) + + DemoListSectionView(content = content) +} + +@Composable +private fun TextLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small + ), + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun LabeledSwitch( + text: String, + checked: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = modifier + .clickable { onCheckedChange(!checked) } + .minimumInteractiveComponentSize() + .padding(end = MaterialTheme.paddings.baseline), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextLabel(text = text) + + Switch( + checked = checked, + onCheckedChange = null, + ) + } +} + +@Composable +private fun DropdownSetting( + text: String, + entries: List, + selectedEntry: T, + modifier: Modifier = Modifier, + onEntrySelected: (entry: T) -> Unit, +) { + var dropdownOffset by remember { mutableStateOf(DpOffset.Zero) } + var showDropdownMenu by remember { mutableStateOf(false) } + + val interactionSource = remember { MutableInteractionSource() } + + Box(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressInteraction = PressInteraction.Press(it) + + interactionSource.emit(pressInteraction) + + dropdownOffset = DpOffset( + x = it.x.toDp(), + y = (it.y - size.height).toDp(), + ) + showDropdownMenu = true + + if (tryAwaitRelease()) { + interactionSource.emit(PressInteraction.Release(pressInteraction)) + } else { + interactionSource.emit(PressInteraction.Cancel(pressInteraction)) + } + } + ) + } + .indication( + interactionSource = interactionSource, + indication = LocalIndication.current, + ) + .minimumInteractiveComponentSize() + .padding(end = MaterialTheme.paddings.baseline), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextLabel(text = text) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val iconRotation by animateFloatAsState( + targetValue = if (showDropdownMenu) -180f else 0f, + label = "icon_rotation_animation" + ) + + Text(text = selectedEntry.toString()) + + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.rotate(iconRotation), + ) + } + } + + DropdownMenu( + expanded = showDropdownMenu, + onDismissRequest = { showDropdownMenu = false }, + offset = dropdownOffset, + ) { + entries.forEach { entry -> + DropdownMenuItem( + text = { Text(text = entry.toString()) }, + onClick = { + onEntrySelected(entry) + showDropdownMenu = false + }, + leadingIcon = { + AnimatedVisibility(entry == selectedEntry) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + } + } + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun AppSettingsPreview() { + val appSettingsRepository = AppSettingsRepository(LocalContext.current) + PillarboxTheme { + AppSettingsView(AppSettingsViewModel(appSettingsRepository)) + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt index 191dc3ff2..82c3eb0f0 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt @@ -5,7 +5,12 @@ package ch.srgssr.pillarbox.demo.ui.showcases.playlists import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -29,10 +35,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.media3.common.Player import ch.srgssr.pillarbox.demo.R @@ -69,11 +75,9 @@ fun CustomPlaybackSettingsShowcase( var pauseAtEndOfItem by remember { mutableStateOf(player.pauseAtEndOfMediaItems) } - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small), - ) { + Column(modifier = modifier) { Box { + var menuOffset by remember { mutableStateOf(DpOffset.Zero) } var showRepeatModeMenu by remember { mutableStateOf(false) } var selectedRepeatModeIndex by remember { mutableIntStateOf( @@ -83,14 +87,38 @@ fun CustomPlaybackSettingsShowcase( ) } + val interactionSource = remember { MutableInteractionSource() } + Row( modifier = Modifier .fillMaxWidth() - .clickable { showRepeatModeMenu = true } - .padding( - horizontal = MaterialTheme.paddings.baseline, - vertical = MaterialTheme.paddings.small, - ), + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressInteraction = PressInteraction.Press(it) + + interactionSource.emit(pressInteraction) + + menuOffset = DpOffset( + x = it.x.toDp(), + y = (it.y - size.height).toDp(), + ) + showRepeatModeMenu = true + + if (tryAwaitRelease()) { + interactionSource.emit(PressInteraction.Release(pressInteraction)) + } else { + interactionSource.emit(PressInteraction.Cancel(pressInteraction)) + } + } + ) + } + .indication( + interactionSource = interactionSource, + indication = LocalIndication.current, + ) + .minimumInteractiveComponentSize() + .padding(horizontal = MaterialTheme.paddings.baseline), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -102,10 +130,7 @@ fun CustomPlaybackSettingsShowcase( DropdownMenu( expanded = showRepeatModeMenu, onDismissRequest = { showRepeatModeMenu = false }, - offset = DpOffset( - x = -MaterialTheme.paddings.small, - y = 0.dp, - ), + offset = menuOffset, ) { repeatModes.forEachIndexed { index, (repeatMode, repeatModeLabel) -> DropdownMenuItem( diff --git a/pillarbox-demo/src/main/res/values/strings.xml b/pillarbox-demo/src/main/res/values/strings.xml index d65550668..839c6ee19 100644 --- a/pillarbox-demo/src/main/res/values/strings.xml +++ b/pillarbox-demo/src/main/res/values/strings.xml @@ -35,4 +35,10 @@ all Pause at end of media items Chapters + Library version + Choose text color + Choose text size + Metrics Overlay + Display an overlay on top of the video surface to show useful information. + Enable metrics overlay diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt index ae6c4a2c3..5781451f5 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt @@ -94,7 +94,7 @@ internal class SessionMetrics internal constructor( fun getTotalBitrate(): Long { val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE - var bitrate = 0L + var bitrate = Format.NO_VALUE.toLong() if (videoBitrate > 0) bitrate += videoBitrate if (audioBitrate > 0) bitrate += audioBitrate return bitrate diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index e08c08cda..533aed35f 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -15,6 +15,7 @@ import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.utils.BitrateUtil.toByteRate import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.Heartbeat import kotlin.coroutines.CoroutineContext @@ -115,8 +116,8 @@ internal class QoSCoordinator( } private fun PlaybackMetrics.toQoSEvent(): QoSEvent { - val bitrateBytes = indicatedBitrate / Byte.SIZE_BYTES - val bandwidthBytes = bandwidth / Byte.SIZE_BYTES + val bitrateBytes = indicatedBitrate.toByteRate() + val bandwidthBytes = bandwidth.toByteRate() return QoSEvent( bandwidth = bandwidthBytes, bitrate = bitrateBytes.toInt(), diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/BitrateUtil.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/BitrateUtil.kt new file mode 100644 index 000000000..90e3ae449 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/BitrateUtil.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.utils + +/** + * Bitrate util + */ +object BitrateUtil { + + /** + * @return Convert Int in bits rate to Int in byte rate. + */ + fun Int.toByteRate(): Int { + return this / Byte.SIZE_BITS + } + + /** + * @return Convert Long in bits rate to Long in byte rate. + */ + fun Long.toByteRate(): Long { + return this / Byte.SIZE_BITS + } +}