Skip to content

Commit bb5da94

Browse files
oceanjulescopybara-github
authored andcommitted
[ui-compose] Add ProgressStateWithTickInterval for position/duration
This ProgressState stateholder is customized to be used by text-based UI elements that are ticking with the media-time. The "grid" for ticking is chosen as the multiples of tickInterval (e.g. 1s, 2s, 3s) and the `position` is only updated then. Text-based UI element is likely to be a string, whose granularity (e.g. for HH:MM:SS seconds are the highest granularity) should be matched with the tickInterval to help syncronise recomposition while keeping it as low as possible. This helps avoid the responsibility of the consumer to use derivedState to cap the recompositions. [demo-compose] Add `TextProgressIndicator`, i.e. textual representations of progress, to `demo-compose`. PiperOrigin-RevId: 777567494
1 parent 7c000e6 commit bb5da94

File tree

10 files changed

+811
-13
lines changed

10 files changed

+811
-13
lines changed

RELEASENOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
* Fix bug where `PlayerSurface` inside re-usable components like
3232
`LazyColumn` didn't work correctly
3333
([#2493](https://github.com/androidx/media/issues/2493)).
34+
* Add `ProgressStateWithTickInterval` class and the corresponding
35+
`rememberProgressStateWithTickInterval` Composable to
36+
`media3-ui-compose` module. This state holder is used in `demo-compose`
37+
to display the current position and duration in text form.
3438
* Downloads:
3539
* OkHttp extension:
3640
* Cronet extension:

demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ package androidx.media3.demo.compose.buttons
1818

1919
import androidx.compose.foundation.layout.Arrangement
2020
import androidx.compose.foundation.layout.Row
21+
import androidx.compose.foundation.layout.Spacer
2122
import androidx.compose.runtime.Composable
2223
import androidx.compose.ui.Alignment
2324
import androidx.compose.ui.Modifier
2425
import androidx.media3.common.Player
26+
import androidx.media3.demo.compose.indicator.TextProgressIndicator
2527

2628
@Composable
2729
internal fun ExtraControls(player: Player, modifier: Modifier = Modifier) {
@@ -30,6 +32,8 @@ internal fun ExtraControls(player: Player, modifier: Modifier = Modifier) {
3032
horizontalArrangement = Arrangement.Center,
3133
verticalAlignment = Alignment.CenterVertically,
3234
) {
35+
TextProgressIndicator(player, Modifier.align(Alignment.CenterVertically))
36+
Spacer(Modifier.weight(1f))
3337
PlaybackSpeedPopUpButton(player)
3438
ShuffleButton(player)
3539
RepeatButton(player)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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.demo.compose.indicator
18+
19+
import androidx.annotation.IntRange
20+
import androidx.compose.foundation.text.BasicText
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.runtime.derivedStateOf
23+
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.ui.Modifier
26+
import androidx.media3.common.Player
27+
import androidx.media3.common.util.Util.getStringForTime
28+
import androidx.media3.ui.compose.state.ProgressStateWithTickInterval
29+
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
30+
31+
/**
32+
* An example of a progress indicator that represents the [Player's][Player]
33+
* [ProgressStateWithTickInterval] in textual form.
34+
*
35+
* It displays the up-to-date current position and duration of the media at the granularity of the
36+
* provided [timeStepSeconds]. For example, for shorter videos, a 1-second granularity
37+
* (`timeSecondsStep = 1`) might be more appropriate, whereas for a multiple-hour long movie, a
38+
* 1-minute granularity (`timeSecondsStep = 60`) might be enough. Tuning this parameter can help you
39+
* avoid unnecessary recompositions.
40+
*
41+
* @param timeStepSeconds Delta of the media time that constitutes a progress step, in seconds.
42+
*/
43+
@Composable
44+
fun TextProgressIndicator(
45+
player: Player,
46+
modifier: Modifier = Modifier,
47+
@IntRange(from = 0) timeStepSeconds: Int = 1,
48+
) {
49+
val progressState =
50+
rememberProgressStateWithTickInterval(
51+
player,
52+
tickIntervalMs = timeStepSeconds.toLong().times(1000),
53+
)
54+
val current = getStringForTime(progressState.currentPositionMs)
55+
// duration will not change as often as current position
56+
val duration by remember { derivedStateOf { getStringForTime(progressState.durationMs) } }
57+
BasicText("$current - $duration", modifier)
58+
}

libraries/common/src/main/java/androidx/media3/common/util/Util.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2788,6 +2788,19 @@ public static String getStringForTime(StringBuilder builder, Formatter formatter
27882788
: formatter.format("%s%02d:%02d", prefix, minutes, seconds).toString();
27892789
}
27902790

2791+
/**
2792+
* Returns the specified millisecond time formatted as a string.
2793+
*
2794+
* @param timeMs The time to format as a string, in milliseconds.
2795+
* @return The time formatted as a string.
2796+
*/
2797+
@UnstableApi
2798+
public static String getStringForTime(long timeMs) {
2799+
StringBuilder builder = new StringBuilder();
2800+
Formatter formatter = new Formatter(builder, Locale.getDefault());
2801+
return getStringForTime(builder, formatter, timeMs);
2802+
}
2803+
27912804
/**
27922805
* Escapes a string so that it's safe for use as a file or directory name on at least FAT32
27932806
* filesystems. FAT32 is the most restrictive of all filesystems still commonly used today.

libraries/common/src/test/java/androidx/media3/common/util/UtilTest.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,9 +1314,35 @@ public void tableExists_withNonExistingTable() {
13141314
}
13151315

13161316
@Test
1317-
public void getStringForTime_withNegativeTime_setsNegativePrefix() {
1318-
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ -35000))
1319-
.isEqualTo("-00:35");
1317+
public void getStringForTime_withRangeOfValues() {
1318+
assertThat(getStringForTime(C.TIME_UNSET)).isEqualTo("00:00");
1319+
assertThat(getStringForTime(0)).isEqualTo("00:00");
1320+
assertThat(getStringForTime(413)).isEqualTo("00:00");
1321+
assertThat(getStringForTime(800)).isEqualTo("00:01");
1322+
assertThat(getStringForTime(10_000)).isEqualTo("00:10");
1323+
assertThat(getStringForTime(65_000)).isEqualTo("01:05");
1324+
assertThat(getStringForTime(3_661_000)).isEqualTo("1:01:01");
1325+
assertThat(getStringForTime(-4_000)).isEqualTo("-00:04");
1326+
}
1327+
1328+
@Test
1329+
public void getStringForTime_withFormatter_withRangeOfValues() {
1330+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ C.TIME_UNSET))
1331+
.isEqualTo("00:00");
1332+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ 0))
1333+
.isEqualTo("00:00");
1334+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ 413))
1335+
.isEqualTo("00:00");
1336+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ 800))
1337+
.isEqualTo("00:01");
1338+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ 10_000))
1339+
.isEqualTo("00:10");
1340+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ 65_000))
1341+
.isEqualTo("01:05");
1342+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ 3_661_000))
1343+
.isEqualTo("1:01:01");
1344+
assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ -4_000))
1345+
.isEqualTo("-00:04");
13201346
}
13211347

13221348
@Test

libraries/ui_compose/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ dependencies {
5656
// Remove the version number once b/385138624 is fixed, GMaven doesn't resolve the BOM above
5757
api 'androidx.compose.foundation:foundation:1.7.6'
5858

59+
testImplementation 'androidx.compose.ui:ui-test-android:1.8.2'
5960
testImplementation 'androidx.compose.ui:ui-test'
6061
testImplementation 'androidx.compose.ui:ui-test-junit4'
6162
testImplementation project(modulePrefix + 'test-utils')
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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.annotation.VisibleForTesting
20+
import androidx.compose.runtime.withFrameMillis
21+
import androidx.media3.common.C
22+
import androidx.media3.common.Player
23+
import androidx.media3.common.listen
24+
import androidx.media3.common.util.Assertions.checkState
25+
import kotlinx.coroutines.CoroutineScope
26+
import kotlinx.coroutines.Job
27+
import kotlinx.coroutines.coroutineScope
28+
import kotlinx.coroutines.delay
29+
import kotlinx.coroutines.isActive
30+
import kotlinx.coroutines.launch
31+
32+
internal class ProgressStateJob(
33+
private val player: Player,
34+
private val scope: CoroutineScope,
35+
private val intervalMsSupplier: () -> Long,
36+
private val scheduledTask: () -> Unit,
37+
) {
38+
private var updateJob: Job? = null
39+
40+
/**
41+
* Subscribes to updates from [Player.Events] to track changes of progress-related information in
42+
* an asynchronous way.
43+
*/
44+
internal suspend fun observeProgress(): Nothing = coroutineScope {
45+
// otherwise we don't update on recomposition of UI, only on Player.Events
46+
cancelPendingUpdatesAndRelaunch()
47+
player.listen { events ->
48+
if (
49+
events.containsAny(
50+
Player.EVENT_IS_PLAYING_CHANGED,
51+
Player.EVENT_POSITION_DISCONTINUITY,
52+
Player.EVENT_TIMELINE_CHANGED,
53+
Player.EVENT_PLAYBACK_PARAMETERS_CHANGED,
54+
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
55+
)
56+
) {
57+
scheduledTask()
58+
if (player.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)) {
59+
cancelPendingUpdatesAndRelaunch()
60+
} else {
61+
updateJob?.cancel()
62+
}
63+
}
64+
}
65+
}
66+
67+
internal fun cancelPendingUpdatesAndRelaunch() {
68+
updateJob?.cancel()
69+
scheduledTask()
70+
updateJob =
71+
scope.launch {
72+
while (isActive) {
73+
smartDelay()
74+
scheduledTask()
75+
}
76+
}
77+
}
78+
79+
/**
80+
* Delay the polling of the [Player] by the time delta that corresponds to the interval supplied
81+
* by [intervalMsSupplier]. If the duration of one step is zero, the polling is suspended until
82+
* the next frame is requested, preventing unnecessarily frequent updates that will not be visible
83+
* on the screen.
84+
*
85+
* Playback speed is taken into account as well, since it expands or shrinks the effective media
86+
* duration.
87+
*/
88+
private suspend fun smartDelay() {
89+
val oneStepDurationMs = intervalMsSupplier()
90+
checkState(
91+
oneStepDurationMs >= 0 || oneStepDurationMs == C.TIME_UNSET,
92+
"Provided intervalMsSupplier is negative: $oneStepDurationMs",
93+
)
94+
if (player.isPlaying && oneStepDurationMs != C.TIME_UNSET) {
95+
if (oneStepDurationMs < MIN_UPDATE_INTERVAL_MS * player.playbackParameters.speed) {
96+
// oneStepDurationMs == 0 is a requested continuous update,
97+
// otherwise throttled by Recomposition frequency
98+
withFrameMillis {}
99+
} else {
100+
val mediaTimeToNextStepMs = oneStepDurationMs - player.currentPosition % oneStepDurationMs
101+
// Convert the specified interval to wall-clock time
102+
var realTimeToNextStepMs = mediaTimeToNextStepMs / player.playbackParameters.speed
103+
// Prevent unnecessarily short sleep which results in increased scheduledTask() frequency
104+
if (realTimeToNextStepMs < MIN_UPDATE_INTERVAL_MS) {
105+
realTimeToNextStepMs += oneStepDurationMs / player.playbackParameters.speed
106+
}
107+
// Prevent infinite delays by 0
108+
delay(realTimeToNextStepMs.toLong().coerceAtLeast(1L))
109+
}
110+
} else if (
111+
(player.playbackState != Player.STATE_ENDED && player.playbackState != Player.STATE_IDLE) ||
112+
oneStepDurationMs == C.TIME_UNSET
113+
) {
114+
delay(PAUSED_UPDATE_INTERVAL_MS)
115+
}
116+
}
117+
}
118+
119+
internal fun getCurrentPositionMsOrDefault(player: Player): Long {
120+
return if (player.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)) {
121+
player.currentPosition
122+
} else {
123+
0
124+
}
125+
}
126+
127+
internal fun getBufferedPositionMsOrDefault(player: Player): Long {
128+
return if (player.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)) {
129+
player.bufferedPosition
130+
} else {
131+
0
132+
}
133+
}
134+
135+
internal fun getDurationMsOrDefault(player: Player): Long {
136+
return if (player.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)) {
137+
player.duration
138+
} else {
139+
C.TIME_UNSET
140+
}
141+
}
142+
143+
// Taking highest frame rate as 120fps, interval is 1000/120
144+
private const val MIN_UPDATE_INTERVAL_MS = 8L
145+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
146+
const val PAUSED_UPDATE_INTERVAL_MS = 1000L

0 commit comments

Comments
 (0)