Skip to content

Commit 9bf985f

Browse files
committed
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.
1 parent 218edf0 commit 9bf985f

File tree

3 files changed

+174
-3
lines changed

3 files changed

+174
-3
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.media3.ui.compose.state
18+
19+
import android.net.Uri
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.LaunchedEffect
22+
import androidx.compose.runtime.getValue
23+
import androidx.compose.runtime.mutableStateOf
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.runtime.setValue
26+
import androidx.media3.common.Player
27+
import androidx.media3.common.listen
28+
import androidx.media3.common.util.UnstableApi
29+
30+
/**
31+
* Remembers the value of a [MetadataState] created based on the passed [Player] and launches a
32+
* coroutine to listen to the [Player's][Player] changes. If the [Player] instance changes between
33+
* compositions, this produces and remembers a new [MetadataState].
34+
*/
35+
@UnstableApi
36+
@Composable
37+
fun rememberMetadataState(player: Player): MetadataState {
38+
val metadataState = remember(player) { MetadataState(player) }
39+
LaunchedEffect(player) { metadataState.observe() }
40+
return metadataState
41+
}
42+
43+
/**
44+
* State that holds information to correctly deal with UI components related to the current
45+
* [MediaItem][androidx.media3.common.MediaItem] metadata.
46+
*
47+
* @property[uri] The URI of the current media item, if available.
48+
*/
49+
@UnstableApi
50+
class MetadataState(private val player: Player) {
51+
var uri by mutableStateOf(player.getMediaItemUri())
52+
private set
53+
54+
suspend fun observe(): Nothing {
55+
player.listen { events ->
56+
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) {
57+
uri = getMediaItemUri()
58+
}
59+
}
60+
}
61+
62+
private fun Player.getMediaItemUri(): Uri? {
63+
return currentMediaItem?.localConfiguration?.uri
64+
}
65+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.media3.ui.compose.state
18+
19+
import androidx.compose.ui.test.junit4.createComposeRule
20+
import androidx.core.net.toUri
21+
import androidx.media3.common.MediaItem
22+
import androidx.media3.common.Player
23+
import androidx.media3.common.SimpleBasePlayer.MediaItemData
24+
import androidx.media3.ui.compose.utils.TestPlayer
25+
import androidx.test.ext.junit.runners.AndroidJUnit4
26+
import com.google.common.truth.Truth.assertThat
27+
import org.junit.Rule
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
31+
/** Unit test for [MetadataState]. */
32+
@RunWith(AndroidJUnit4::class)
33+
class MetadataStateTest {
34+
35+
@get:Rule
36+
val composeTestRule = createComposeRule()
37+
38+
@Test
39+
fun uri_emptyPlaylist_returnsNull() {
40+
val player = TestPlayer(
41+
playbackState = Player.STATE_IDLE,
42+
playlist = emptyList(),
43+
)
44+
45+
lateinit var state: MetadataState
46+
composeTestRule.setContent { state = rememberMetadataState(player) }
47+
48+
assertThat(state.uri).isNull()
49+
}
50+
51+
@Test
52+
fun uri_singleItemWithoutUri_returnsNull() {
53+
val player = TestPlayer(
54+
playlist = listOf(
55+
MediaItemData.Builder("uid_1").build(),
56+
),
57+
)
58+
59+
lateinit var state: MetadataState
60+
composeTestRule.setContent { state = rememberMetadataState(player) }
61+
62+
assertThat(state.uri).isNull()
63+
}
64+
65+
@Test
66+
fun uri_singleItemWithUri_returnsTheUri() {
67+
val uri = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri()
68+
val player = TestPlayer(
69+
playlist = listOf(
70+
MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri)).build(),
71+
),
72+
)
73+
74+
lateinit var state: MetadataState
75+
composeTestRule.setContent { state = rememberMetadataState(player) }
76+
77+
assertThat(state.uri).isEqualTo(uri)
78+
}
79+
80+
@Test
81+
fun uri_transitionBetweenItems_returnsUpdatedUri() {
82+
val uri1 = "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd".toUri()
83+
val uri2 =
84+
"https://storage.googleapis.com/exoplayer-test-media-1/mp4/dizzy-with-tx3g.mp4".toUri()
85+
val player = TestPlayer(
86+
playlist = listOf(
87+
MediaItemData.Builder("uid_1").setMediaItem(MediaItem.fromUri(uri1)).build(),
88+
MediaItemData.Builder("uid_2").build(),
89+
MediaItemData.Builder("uid_3").setMediaItem(MediaItem.fromUri(uri2)).build(),
90+
),
91+
)
92+
93+
lateinit var state: MetadataState
94+
composeTestRule.setContent { state = rememberMetadataState(player) }
95+
96+
assertThat(state.uri).isEqualTo(uri1)
97+
98+
player.seekToNext()
99+
composeTestRule.waitForIdle()
100+
101+
assertThat(state.uri).isNull()
102+
103+
player.seekToNext()
104+
composeTestRule.waitForIdle()
105+
106+
assertThat(state.uri).isEqualTo(uri2)
107+
}
108+
}

libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,7 @@ internal class TestPlayer(
172172

173173
fun setDuration(uid: String, durationMs: Long) {
174174
val index = state.playlist.indexOfFirst { it.uid == uid }
175-
if (index == -1) {
176-
throw IllegalArgumentException("Playlist does not contain item with uid: $uid")
177-
}
175+
require(index > -1) { "Playlist does not contain item with uid: $uid" }
178176
val modifiedPlaylist = buildList {
179177
addAll(state.playlist)
180178
set(index, state.playlist[index].buildUpon().setDurationUs(msToUs(durationMs)).build())

0 commit comments

Comments
 (0)