Skip to content

Add MetadataState to :ui:compose #2700

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down