From b6a3d09945c6e8b9c16b6680bdcda5debd2b41c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Wed, 6 Aug 2025 13:22:16 +0200 Subject: [PATCH 1/4] Add `MetadataState` to `:ui:compose` This commit introduces `MetadataState`, a Compose state that exposes metadata information about the current `MediaItem`. At the moment, it only provides the media uri. --- .../media3/ui/compose/state/MetadataState.kt | 65 +++++++++++ .../ui/compose/state/MetadataStateTest.kt | 108 ++++++++++++++++++ .../media3/ui/compose/utils/TestPlayer.kt | 4 +- 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt create mode 100644 libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt new file mode 100644 index 0000000000..aaac62eeb2 --- /dev/null +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.media3.ui.compose.state + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.media3.common.Player +import androidx.media3.common.listen +import androidx.media3.common.util.UnstableApi + +/** + * Remembers the value of a [MetadataState] created based on the passed [Player] and launches a + * coroutine to listen to the [Player's][Player] changes. If the [Player] instance changes between + * compositions, this produces and remembers a new [MetadataState]. + */ +@UnstableApi +@Composable +fun rememberMetadataState(player: Player): MetadataState { + val metadataState = remember(player) { MetadataState(player) } + LaunchedEffect(player) { metadataState.observe() } + return metadataState +} + +/** + * State that holds information to correctly deal with UI components related to the current + * [MediaItem][androidx.media3.common.MediaItem] metadata. + * + * @property[uri] The URI of the current media item, if available. + */ +@UnstableApi +class MetadataState(private val player: Player) { + var uri by mutableStateOf(player.getMediaItemUri()) + private set + + suspend fun observe(): Nothing { + player.listen { events -> + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { + uri = getMediaItemUri() + } + } + } + + private fun Player.getMediaItemUri(): Uri? { + return currentMediaItem?.localConfiguration?.uri + } +} diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt new file mode 100644 index 0000000000..ded052b2b2 --- /dev/null +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.media3.ui.compose.state + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer.MediaItemData +import androidx.media3.ui.compose.utils.TestPlayer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Unit test for [MetadataState]. */ +@RunWith(AndroidJUnit4::class) +class MetadataStateTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun uri_emptyPlaylist_returnsNull() { + val player = TestPlayer( + playbackState = Player.STATE_IDLE, + playlist = emptyList(), + ) + + lateinit var state: MetadataState + composeTestRule.setContent { state = rememberMetadataState(player) } + + assertThat(state.uri).isNull() + } + + @Test + fun uri_singleItemWithoutUri_returnsNull() { + val player = TestPlayer( + playlist = listOf( + MediaItemData.Builder("uid_1").build(), + ), + ) + + lateinit var state: MetadataState + composeTestRule.setContent { state = rememberMetadataState(player) } + + assertThat(state.uri).isNull() + } + + @Test + fun uri_singleItemWithUri_returnsTheUri() { + val uri = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri() + val player = TestPlayer( + playlist = listOf( + MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri)).build(), + ), + ) + + lateinit var state: MetadataState + composeTestRule.setContent { state = rememberMetadataState(player) } + + assertThat(state.uri).isEqualTo(uri) + } + + @Test + fun uri_transitionBetweenItems_returnsUpdatedUri() { + val uri1 = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri() + val uri2 = + "https://storage.googleapis.com/exoplayer-test-media-1/mp4/dizzy-with-tx3g.mp4".toUri() + val player = TestPlayer( + playlist = listOf( + MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri1)).build(), + MediaItemData.Builder("uid_2").build(), + MediaItemData.Builder("uid_3").setMediaItem(MediaItem.fromUri(uri2)).build(), + ), + ) + + lateinit var state: MetadataState + composeTestRule.setContent { state = rememberMetadataState(player) } + + assertThat(state.uri).isEqualTo(uri1) + + player.seekToNext() + composeTestRule.waitForIdle() + + assertThat(state.uri).isNull() + + player.seekToNext() + composeTestRule.waitForIdle() + + assertThat(state.uri).isEqualTo(uri2) + } +} diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt index d6000261f9..2d0fc26b8e 100644 --- a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt @@ -172,9 +172,7 @@ internal class TestPlayer( fun setDuration(uid: String, durationMs: Long) { val index = state.playlist.indexOfFirst { it.uid == uid } - if (index == -1) { - throw IllegalArgumentException("Playlist does not contain item with uid: $uid") - } + require(index > -1) { "Playlist does not contain item with uid: $uid" } val modifiedPlaylist = buildList { addAll(state.playlist) set(index, state.playlist[index].buildUpon().setDurationUs(msToUs(durationMs)).build()) From 681b250e49f0c672f0cc8ac4726d3244301b4af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 7 Aug 2025 09:09:45 +0200 Subject: [PATCH 2/4] Check for `Player.COMMAND_GET_CURRENT_MEDIA_ITEM` before getting the current media URI --- .../media3/ui/compose/state/MetadataState.kt | 19 ++++++++++++----- .../ui/compose/state/MetadataStateTest.kt | 21 +++++++++++++++++++ .../media3/ui/compose/utils/TestPlayer.kt | 2 +- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt index aaac62eeb2..fdbd048860 100644 --- a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt @@ -48,18 +48,27 @@ fun rememberMetadataState(player: Player): MetadataState { */ @UnstableApi class MetadataState(private val player: Player) { - var uri by mutableStateOf(player.getMediaItemUri()) + var uri by mutableStateOf(player.getMediaItemUriWithCommandCheck()) private set suspend fun observe(): Nothing { player.listen { events -> - if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { - uri = getMediaItemUri() + if ( + events.containsAny( + Player.EVENT_AVAILABLE_COMMANDS_CHANGED, + Player.EVENT_MEDIA_ITEM_TRANSITION, + ) + ) { + uri = getMediaItemUriWithCommandCheck() } } } - private fun Player.getMediaItemUri(): Uri? { - return currentMediaItem?.localConfiguration?.uri + private fun Player.getMediaItemUriWithCommandCheck(): Uri? { + return if (isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)) { + currentMediaItem?.localConfiguration?.uri + } else { + null + } } } diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt index ded052b2b2..766c625ae2 100644 --- a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt @@ -105,4 +105,25 @@ class MetadataStateTest { assertThat(state.uri).isEqualTo(uri2) } + + @Test + fun uri_getCurrentMediaItemCommandBecomesAvailable_returnsUpdatedUri() { + val uri = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri() + val player = TestPlayer( + playlist = listOf( + MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri)).build(), + ), + ) + player.removeCommands(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) + + lateinit var state: MetadataState + composeTestRule.setContent { state = rememberMetadataState(player) } + + assertThat(state.uri).isNull() + + player.addCommands(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) + composeTestRule.waitForIdle() + + assertThat(state.uri).isEqualTo(uri) + } } diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt index 2d0fc26b8e..e92de109d8 100644 --- a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt @@ -172,7 +172,7 @@ internal class TestPlayer( fun setDuration(uid: String, durationMs: Long) { val index = state.playlist.indexOfFirst { it.uid == uid } - require(index > -1) { "Playlist does not contain item with uid: $uid" } + require(index >= 0) { "Playlist does not contain item with uid: $uid" } val modifiedPlaylist = buildList { addAll(state.playlist) set(index, state.playlist[index].buildUpon().setDurationUs(msToUs(durationMs)).build()) From 2179e5bb671c58057a8fd3f04cf11c220ffad433 Mon Sep 17 00:00:00 2001 From: oceanjules Date: Thu, 7 Aug 2025 15:41:59 +0100 Subject: [PATCH 3/4] Format with google-java-format and add RELEAESENOTES --- RELEASENOTES.md | 2 + .../media3/ui/compose/state/MetadataState.kt | 8 +-- .../ui/compose/state/MetadataStateTest.kt | 50 ++++++++----------- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c60655a487..acc8f8bb71 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -144,6 +144,8 @@ `rememberProgressStateWithTickCount` Composable to `media3-ui-compose` module. This state holder is used in `demo-compose` to display progress as a horizontal read-only progress bar. + * Add `MetadataState` class and the corresponding `rememberMetadataState` + Composable to `media3-ui-compose` module. * Downloads: * OkHttp extension: * Cronet extension: diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt index fdbd048860..e9d39adabf 100644 --- a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt @@ -54,10 +54,10 @@ class MetadataState(private val player: Player) { suspend fun observe(): Nothing { player.listen { events -> if ( - events.containsAny( - Player.EVENT_AVAILABLE_COMMANDS_CHANGED, - Player.EVENT_MEDIA_ITEM_TRANSITION, - ) + events.containsAny( + Player.EVENT_AVAILABLE_COMMANDS_CHANGED, + Player.EVENT_MEDIA_ITEM_TRANSITION, + ) ) { uri = getMediaItemUriWithCommandCheck() } diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt index 766c625ae2..4a853929c3 100644 --- a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt @@ -32,15 +32,11 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MetadataStateTest { - @get:Rule - val composeTestRule = createComposeRule() + @get:Rule val composeTestRule = createComposeRule() @Test fun uri_emptyPlaylist_returnsNull() { - val player = TestPlayer( - playbackState = Player.STATE_IDLE, - playlist = emptyList(), - ) + val player = TestPlayer(playbackState = Player.STATE_IDLE, playlist = emptyList()) lateinit var state: MetadataState composeTestRule.setContent { state = rememberMetadataState(player) } @@ -50,11 +46,7 @@ class MetadataStateTest { @Test fun uri_singleItemWithoutUri_returnsNull() { - val player = TestPlayer( - playlist = listOf( - MediaItemData.Builder("uid_1").build(), - ), - ) + val player = TestPlayer(playlist = listOf(MediaItemData.Builder("uid_1").build())) lateinit var state: MetadataState composeTestRule.setContent { state = rememberMetadataState(player) } @@ -65,11 +57,11 @@ class MetadataStateTest { @Test fun uri_singleItemWithUri_returnsTheUri() { val uri = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri() - val player = TestPlayer( - playlist = listOf( - MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri)).build(), - ), - ) + val player = + TestPlayer( + playlist = + listOf(MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri)).build()) + ) lateinit var state: MetadataState composeTestRule.setContent { state = rememberMetadataState(player) } @@ -82,13 +74,15 @@ class MetadataStateTest { val uri1 = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri() val uri2 = "https://storage.googleapis.com/exoplayer-test-media-1/mp4/dizzy-with-tx3g.mp4".toUri() - val player = TestPlayer( - playlist = listOf( - MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri1)).build(), - MediaItemData.Builder("uid_2").build(), - MediaItemData.Builder("uid_3").setMediaItem(MediaItem.fromUri(uri2)).build(), - ), - ) + val player = + TestPlayer( + playlist = + listOf( + MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri1)).build(), + MediaItemData.Builder("uid_2").build(), + MediaItemData.Builder("uid_3").setMediaItem(MediaItem.fromUri(uri2)).build(), + ) + ) lateinit var state: MetadataState composeTestRule.setContent { state = rememberMetadataState(player) } @@ -109,11 +103,11 @@ class MetadataStateTest { @Test fun uri_getCurrentMediaItemCommandBecomesAvailable_returnsUpdatedUri() { val uri = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri() - val player = TestPlayer( - playlist = listOf( - MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri)).build(), - ), - ) + val player = + TestPlayer( + playlist = + listOf(MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri)).build()) + ) player.removeCommands(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) lateinit var state: MetadataState From 4860dcd489068d7ec2395bc2b1f10bb920b79f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 15 Aug 2025 08:41:31 +0200 Subject: [PATCH 4/4] Replace usage of `listen` with `listenTo` --- .../media3/ui/compose/state/MetadataState.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt index e9d39adabf..4cc3d8eb4c 100644 --- a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.media3.common.Player -import androidx.media3.common.listen +import androidx.media3.common.listenTo import androidx.media3.common.util.UnstableApi /** @@ -52,15 +52,8 @@ class MetadataState(private val player: Player) { private set suspend fun observe(): Nothing { - player.listen { events -> - if ( - events.containsAny( - Player.EVENT_AVAILABLE_COMMANDS_CHANGED, - Player.EVENT_MEDIA_ITEM_TRANSITION, - ) - ) { - uri = getMediaItemUriWithCommandCheck() - } + player.listenTo(Player.EVENT_AVAILABLE_COMMANDS_CHANGED, Player.EVENT_MEDIA_ITEM_TRANSITION) { + uri = getMediaItemUriWithCommandCheck() } }