diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt index 4f142e0b2..47f7396bf 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt @@ -4,6 +4,7 @@ */ package ch.srgssr.pillarbox.player +import androidx.media3.common.Format import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException @@ -14,9 +15,9 @@ import androidx.media3.common.Timeline import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.Tracks import androidx.media3.common.VideoSize -import ch.srgssr.pillarbox.player.extension.computeAspectRatio import ch.srgssr.pillarbox.player.extension.getCurrentMediaItems import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed +import ch.srgssr.pillarbox.player.extension.video import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.channels.awaitClose @@ -25,11 +26,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.isActive import kotlin.time.Duration @@ -289,17 +288,15 @@ fun Player.videoSizeAsFlow(): Flow = callbackFlow { } /** - * Get aspect ratio as flow + * Get aspect ratio of the current video as [Flow]. * - * @param defaultAspectRatio Aspect ratio when [Player.getVideoSize] is unknown or audio. + * @param defaultAspectRatio The aspect ratio when the video size is unknown, or for audio content. */ -fun Player.getAspectRatioAsFlow(defaultAspectRatio: Float): Flow = - videoSizeAsFlow() - .filterNot { it == VideoSize.UNKNOWN } - .map { - it.computeAspectRatio(defaultAspectRatio) - } - .onEmpty { emit(defaultAspectRatio) } +fun Player.getAspectRatioAsFlow(defaultAspectRatio: Float): Flow { + return getCurrentTracksAsFlow() + .map { it.getVideoAspectRatioOrElse(defaultAspectRatio) } + .distinctUntilChanged() +} /** * Get track selection parameters as flow [Player.getTrackSelectionParameters] @@ -348,6 +345,16 @@ private suspend fun ProducerScope.addPlayerListener(player: Player, liste } } +private fun Tracks.getVideoAspectRatioOrElse(defaultAspectRatio: Float): Float { + val format = video.find { it.isSelected }?.getTrackFormat(0) + + return if (format == null || format.height <= 0 || format.width == Format.NO_VALUE) { + defaultAspectRatio + } else { + format.width * format.pixelWidthHeightRatio / format.height.toFloat() + } +} + /** * Default update interval. */ diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPlayerCallbackFlow.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPlayerCallbackFlow.kt index 76d059dff..ca1a0d320 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPlayerCallbackFlow.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPlayerCallbackFlow.kt @@ -5,13 +5,18 @@ package ch.srgssr.pillarbox.player import androidx.media3.common.C +import androidx.media3.common.Format import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Player.Commands import androidx.media3.common.Timeline +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks import androidx.media3.common.VideoSize +import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import ch.srgssr.pillarbox.player.test.utils.PlayerListenerCommander import ch.srgssr.pillarbox.player.utils.StringUtil @@ -26,13 +31,16 @@ import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals -@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) class TestPlayerCallbackFlow { private lateinit var player: Player private lateinit var dispatcher: CoroutineDispatcher @Before + @OptIn(ExperimentalCoroutinesApi::class) fun setUp() { dispatcher = UnconfinedTestDispatcher() player = mockk(relaxed = true) @@ -354,4 +362,198 @@ class TestPlayerCallbackFlow { ensureAllEventsConsumed() } } + + @Test + fun `get aspect ratio as flow, default aspect ratio`() = runTest { + val fakePlayer = PlayerListenerCommander(player) + + fakePlayer.getAspectRatioAsFlow(16 / 9f).test { + assertEquals(16 / 9f, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun `get aspect ratio as flow, empty tracks`() = runTest { + val fakePlayer = PlayerListenerCommander(player) + + fakePlayer.getAspectRatioAsFlow(0f).test { + fakePlayer.onTracksChanged(Tracks.EMPTY) + + assertEquals(0f, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun `get aspect ratio as flow, video tracks`() = runTest { + val videoTracks = Tracks( + listOf( + createTrackGroup( + selectedIndex = 1, + createVideoFormat("v1", width = 800, height = 600), + createVideoFormat("v2", width = 1440, height = 900), + createVideoFormat("v3", width = 1920, height = 1080), + ) + ) + ) + + val fakePlayer = PlayerListenerCommander(player) + + fakePlayer.getAspectRatioAsFlow(0f).test { + fakePlayer.onTracksChanged(videoTracks) + + assertEquals(0f, awaitItem()) + assertEquals(4 / 3f, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun `get aspect ratio as flow, video tracks no video size`() = runTest { + val videoTracks = Tracks( + listOf( + createTrackGroup( + selectedIndex = 1, + createVideoFormat("v1", width = Format.NO_VALUE, height = Format.NO_VALUE), + createVideoFormat("v2", width = Format.NO_VALUE, height = Format.NO_VALUE), + createVideoFormat("v3", width = Format.NO_VALUE, height = Format.NO_VALUE), + ) + ) + ) + + val fakePlayer = PlayerListenerCommander(player) + + fakePlayer.getAspectRatioAsFlow(0f).test { + fakePlayer.onTracksChanged(videoTracks) + + assertEquals(0f, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun `get aspect ratio as flow, video tracks without selection`() = runTest { + val videoTracksWithoutSelection = Tracks( + listOf( + createTrackGroup( + selectedIndex = -1, + createVideoFormat("v1", width = 800, height = 600), + createVideoFormat("v2", width = 1440, height = 900), + createVideoFormat("v3", width = 1920, height = 1080), + ) + ) + ) + + val fakePlayer = PlayerListenerCommander(player) + + fakePlayer.getAspectRatioAsFlow(0f).test { + fakePlayer.onTracksChanged(videoTracksWithoutSelection) + + assertEquals(0f, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun `get aspect ratio as flow, audio tracks`() = runTest { + val audioTracks = Tracks( + listOf( + createTrackGroup( + selectedIndex = 1, + createAudioFormat("v1"), + createAudioFormat("v2"), + createAudioFormat("v3"), + ) + ) + ) + + val fakePlayer = PlayerListenerCommander(player) + + fakePlayer.getAspectRatioAsFlow(0f).test { + fakePlayer.onTracksChanged(audioTracks) + + assertEquals(0f, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun `get aspect ratio as flow, changing tracks`() = runTest { + val videoTracksWithoutSelection = Tracks( + listOf( + createTrackGroup( + selectedIndex = 1, + createVideoFormat("v1", width = 800, height = 600), + createVideoFormat("v2", width = 1440, height = 900), + createVideoFormat("v3", width = 1920, height = 1080), + ) + ) + ) + val audioTracks = Tracks( + listOf( + createTrackGroup( + selectedIndex = 1, + createAudioFormat("v1"), + createAudioFormat("v2"), + createAudioFormat("v3"), + ) + ) + ) + + val fakePlayer = PlayerListenerCommander(player) + + fakePlayer.getAspectRatioAsFlow(0f).test { + fakePlayer.onTracksChanged(videoTracksWithoutSelection) + fakePlayer.onTracksChanged(audioTracks) + + assertEquals(0f, awaitItem()) + assertEquals(4 / 3f, awaitItem()) + assertEquals(0f, awaitItem()) + ensureAllEventsConsumed() + } + } + + private companion object { + private const val AUDIO_MIME_TYPE = MimeTypes.AUDIO_MP4 + private const val VIDEO_MIME_TYPE = MimeTypes.VIDEO_H265 + + private fun createAudioFormat(label: String): Format { + return Format.Builder() + .setId("id:$label") + .setLabel(label) + .setLanguage("fr") + .setContainerMimeType(AUDIO_MIME_TYPE) + .build() + } + + private fun createVideoFormat( + label: String, + width: Int, + height: Int, + ): Format { + return Format.Builder() + .setId("id:$label") + .setLabel(label) + .setLanguage("fr") + .setWidth(width) + .setHeight(height) + .setContainerMimeType(VIDEO_MIME_TYPE) + .build() + } + + private fun createTrackGroup( + selectedIndex: Int, + vararg formats: Format, + ): Tracks.Group { + val trackGroup = TrackGroup(*formats) + val trackSupport = IntArray(formats.size) { + C.FORMAT_HANDLED + } + val selected = BooleanArray(formats.size) { index -> + index == selectedIndex + } + return Tracks.Group(trackGroup, false, trackSupport, selected) + } + } } diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/extension/ComposablePlayer.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/extension/ComposablePlayer.kt index 04130c373..55c137855 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/extension/ComposablePlayer.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/extension/ComposablePlayer.kt @@ -213,9 +213,9 @@ fun Player.videoSizeAsState(): State { } /** - * Get aspect ratio as state computed from [Player.getVideoSize] + * Get aspect ratio of the current video as [State]. * - * @param defaultAspectRatio The aspect ratio when video size is unknown or for audio content. + * @param defaultAspectRatio The aspect ratio when the video size is unknown, or for audio content. */ @Composable fun Player.getAspectRatioAsState(defaultAspectRatio: Float): FloatState { diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt index 23dc962bc..f12da6f7a 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.clipToBounds @@ -24,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.Player +import ch.srgssr.pillarbox.player.extension.video import ch.srgssr.pillarbox.ui.ScaleMode import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView import ch.srgssr.pillarbox.ui.extension.getAspectRatioAsState @@ -31,13 +35,14 @@ import ch.srgssr.pillarbox.ui.extension.getAspectRatioAsState /** * Pillarbox player surface * - * @param player The player to render in this SurfaceView - * @param modifier The modifier to be applied to the layout. - * @param scaleMode The scale mode to use. - * @param contentAlignment The "letterboxing" content alignment inside the parent. + * @param player The player to render in this [SurfaceView]. + * @param modifier The [Modifier] to be applied to the layout. + * @param scaleMode The scale mode to use. Only used for video content. Only used when the aspect ratio is strictly positive. + * @param contentAlignment The "letterboxing" content alignment inside the parent. Only used when the aspect ratio is strictly positive. * @param defaultAspectRatio The aspect ratio to use while video is loading or for audio content. - * @param displayDebugView When true displays debug information on top of the surface. - * @param surfaceContent The Composable content to display on top of the Surface. By default render subtitles. + * @param displayDebugView When `true`, displays debug information on top of the surface. Only used when the aspect ratio is strictly positive. + * @param surfaceContent The Composable content to display on top of the [SurfaceView]. By default render the subtitles. Only used when the aspect + * ratio is strictly positive. */ @Composable fun PlayerSurface( @@ -49,9 +54,21 @@ fun PlayerSurface( displayDebugView: Boolean = false, surfaceContent: @Composable (BoxScope.() -> Unit)? = { ExoPlayerSubtitleView(player = player) }, ) { - val videoAspectRatio by player.getAspectRatioAsState( - defaultAspectRatio = defaultAspectRatio ?: 1.0f - ) + var lastKnownVideoAspectRatio by remember { mutableFloatStateOf(defaultAspectRatio ?: 0f) } + val videoAspectRatio by player.getAspectRatioAsState(defaultAspectRatio = lastKnownVideoAspectRatio) + + // If the media has tracks, but no video tracks, we reset the aspect ratio to 0 + if (!player.currentTracks.isEmpty && player.currentTracks.video.isEmpty()) { + lastKnownVideoAspectRatio = 0f + } else if (videoAspectRatio > 0f) { + lastKnownVideoAspectRatio = videoAspectRatio + } + + if (lastKnownVideoAspectRatio <= 0f) { + Box(modifier) + return + } + BoxWithConstraints( contentAlignment = contentAlignment, modifier = modifier.clipToBounds() @@ -62,13 +79,13 @@ fun PlayerSurface( val videoSurfaceModifier = when (scaleMode) { ScaleMode.Fit -> { - Modifier.aspectRatio(videoAspectRatio, viewAspectRatio > videoAspectRatio) + Modifier.aspectRatio(lastKnownVideoAspectRatio, viewAspectRatio > lastKnownVideoAspectRatio) } ScaleMode.Crop -> { Modifier .fillMaxSize() - .aspectRatio(videoAspectRatio, viewAspectRatio <= videoAspectRatio) + .aspectRatio(lastKnownVideoAspectRatio, viewAspectRatio <= lastKnownVideoAspectRatio) } ScaleMode.Fill -> { @@ -78,23 +95,24 @@ fun PlayerSurface( AndroidPlayerSurfaceView(modifier = videoSurfaceModifier, player = player) - val overlayModifier = if (scaleMode != ScaleMode.Crop) { - videoSurfaceModifier - } else { - Modifier.fillMaxSize() - } surfaceContent?.let { + val overlayModifier = if (scaleMode != ScaleMode.Crop) { + videoSurfaceModifier + } else { + Modifier.fillMaxSize() + } + Box(modifier = overlayModifier, content = it) } if (displayDebugView) { Column(modifier = Modifier.align(Alignment.TopStart)) { BasicText( - text = "size: ${width}x$height", + text = "Size: ${width}x$height", color = { Color.Green } ) BasicText( - text = "Aspect view: $viewAspectRatio video: $videoAspectRatio", + text = "Aspect ratio view: $viewAspectRatio, video: $lastKnownVideoAspectRatio", color = { Color.Green } ) } @@ -109,7 +127,7 @@ fun PlayerSurface( * @param modifier The modifier to use to layout. */ @Composable -fun DebugPlayerView(modifier: Modifier) { +private fun DebugPlayerView(modifier: Modifier) { Canvas(modifier = modifier) { drawLine( color = Color.Green, @@ -137,7 +155,7 @@ fun DebugPlayerView(modifier: Modifier) { * @param modifier The modifier to be applied to the layout. */ @Composable -internal fun AndroidPlayerSurfaceView(player: Player, modifier: Modifier = Modifier) { +private fun AndroidPlayerSurfaceView(player: Player, modifier: Modifier = Modifier) { AndroidView( /* * On some devices (Pixel 2 XL Android 11) @@ -160,7 +178,7 @@ internal fun AndroidPlayerSurfaceView(player: Player, modifier: Modifier = Modif /** * Player surface view */ -internal class PlayerSurfaceView(context: Context) : SurfaceView(context) { +private class PlayerSurfaceView(context: Context) : SurfaceView(context) { /** * Player if null is passed just clear surface