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 new file mode 100644 index 0000000000..4cc3d8eb4c --- /dev/null +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/MetadataState.kt @@ -0,0 +1,67 @@ +/* + * 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.listenTo +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.getMediaItemUriWithCommandCheck()) + private set + + suspend fun observe(): Nothing { + player.listenTo(Player.EVENT_AVAILABLE_COMMANDS_CHANGED, Player.EVENT_MEDIA_ITEM_TRANSITION) { + uri = getMediaItemUriWithCommandCheck() + } + } + + 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 new file mode 100644 index 0000000000..4a853929c3 --- /dev/null +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/MetadataStateTest.kt @@ -0,0 +1,123 @@ +/* + * 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) + } + + @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 d6000261f9..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,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 >= 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())