Skip to content

Commit ea2303e

Browse files
oceanjulescopybara-github
authored andcommitted
[ui-compose-material3] Add text progress Composable
The Composable underneath is `BasicText` which is not smart enough to ensure that dynamic text, like video timestamp, doesn't cause layout to reflow. Usually, there are a couple of ways to ensure the width remains constant: * `Monospaced` font (every character has the same width) * `fontFeatureSettings` parameter of the `TextStyle` (each digit has the same width, normal characters remain proportional) * `TextMeasurer` (measure the widest "worst-case" string like 88:88, apply that to all other numbers) The default font on Android (Roboto) is well-designed, and the width differences between its numerals are subtle to the naked eye. What is making it harder to test in the demo app is the flexible `Spacer` that takes up the *remaining* space. Ideally, adding the tests that check the width of various strings **would** show that, as of now, the implementation is width-independent and robust. The problem is that the Android test environment is intentionally designed to prevent this kind of test from working. It does **not** use the standard "Roboto" font, but a basic, non-proportional test font (i.e. all characters have the same width). This is done to prevent visual inconsistencies and flakiness in screenshot and UI tests. In fact, loading custom fonts in this environment is a known difficulty, so no matter what FontFamily we specify, the test environment will likely override it with a font that has fixed-width numerals. Given all this, we shall still apply one of the fixes to ensure the solution will work on real world devices. #cherrypick PiperOrigin-RevId: 829452658
1 parent c5b28be commit ea2303e

File tree

5 files changed

+519
-4
lines changed
  • demos/compose/src/main/java/androidx/media3/demo/compose/buttons
  • libraries
    • ui_compose_material3/src
      • main/java/androidx/media3/ui/compose/material3/indicator
      • test/java/androidx/media3/ui/compose/material3/indicator
    • ui_compose/src/main/java/androidx/media3/ui/compose/indicators

5 files changed

+519
-4
lines changed

RELEASENOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
* UI:
4646
* Use `BidiFormatter` to correctly display punctuation in RTL text
4747
subtitles ([#11214](https://github.com/google/ExoPlayer/issues/11214)).
48+
* Add `TimeText` composable to `media3-ui-compose-material3` for
49+
displaying player progress in a textual form. It can be configured to
50+
show the current position, duration, or remaining time.
4851
* Downloads:
4952
* OkHttp extension:
5053
* Cronet extension:

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column
2323
import androidx.compose.foundation.layout.Row
2424
import androidx.compose.foundation.layout.Spacer
2525
import androidx.compose.foundation.layout.fillMaxWidth
26+
import androidx.compose.foundation.layout.padding
2627
import androidx.compose.foundation.layout.size
2728
import androidx.compose.foundation.shape.CircleShape
2829
import androidx.compose.runtime.Composable
@@ -32,7 +33,6 @@ import androidx.compose.ui.graphics.Color
3233
import androidx.compose.ui.unit.dp
3334
import androidx.media3.common.Player
3435
import androidx.media3.demo.compose.indicator.HorizontalLinearProgressIndicator
35-
import androidx.media3.demo.compose.indicator.TextProgressIndicator
3636
import androidx.media3.ui.compose.material3.buttons.MuteButton
3737
import androidx.media3.ui.compose.material3.buttons.NextButton
3838
import androidx.media3.ui.compose.material3.buttons.PlayPauseButton
@@ -41,6 +41,7 @@ import androidx.media3.ui.compose.material3.buttons.RepeatButton
4141
import androidx.media3.ui.compose.material3.buttons.SeekBackButton
4242
import androidx.media3.ui.compose.material3.buttons.SeekForwardButton
4343
import androidx.media3.ui.compose.material3.buttons.ShuffleButton
44+
import androidx.media3.ui.compose.material3.indicator.PositionAndDurationText
4445

4546
@Composable
4647
private fun RowControls(
@@ -93,11 +94,12 @@ internal fun BoxScope.Controls(player: Player) {
9394
Column(Modifier.fillMaxWidth().align(Alignment.BottomCenter)) {
9495
HorizontalLinearProgressIndicator(player, Modifier.fillMaxWidth())
9596
Row(
96-
modifier = Modifier.fillMaxWidth().background(Color.Gray.copy(alpha = 0.4f)),
97-
horizontalArrangement = Arrangement.Center,
97+
modifier =
98+
Modifier.fillMaxWidth().background(Color.Gray.copy(alpha = 0.4f)).padding(start = 15.dp),
99+
horizontalArrangement = Arrangement.Start,
98100
verticalAlignment = Alignment.CenterVertically,
99101
) {
100-
TextProgressIndicator(player, Modifier.align(Alignment.CenterVertically))
102+
PositionAndDurationText(player)
101103
Spacer(Modifier.weight(1f))
102104
PlaybackSpeedPopUpButton(player)
103105
ShuffleButton(player)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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.indicators
18+
19+
import androidx.annotation.IntRange
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.rememberCoroutineScope
22+
import androidx.media3.common.Player
23+
import androidx.media3.common.util.UnstableApi
24+
import androidx.media3.ui.compose.state.ProgressStateWithTickInterval
25+
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
26+
import kotlinx.coroutines.CoroutineScope
27+
28+
/**
29+
* A Composable that provides player progress information to a content lambda, allowing for the
30+
* creation of custom progress indicators.
31+
*
32+
* This function does not render any UI itself. Instead, it manages the state of the player's
33+
* progress and exposes a [ProgressStateWithTickInterval] object to its [content] lambda. This
34+
* allows for complete control over the layout and appearance of the progress display.
35+
*
36+
* Note that the reliance on [ProgressStateWithTickInterval] implies that the UI responsiveness and
37+
* precision is optimised with the media-clock in mind. That makes it most suitable for displaying
38+
* time-based text progress (rather than pixel-based slider/bar), hence the name of this Composable.
39+
*
40+
* It serves as a state provider, decoupling the logic of listening to player progress from the UI
41+
* presentation, which is a flexible pattern recommended for building reusable Composables.
42+
*
43+
* Example usage:
44+
* ```
45+
* TimeText(player) {
46+
* // `this` is a `ProgressStateWithTickInterval`
47+
* val currentPosition = Util.getStringForTime(this.currentPositionMs)
48+
* val duration = Util.getStringForTime(this.durationMs)
49+
* Text("$currentPosition / $duration")
50+
* }
51+
* ```
52+
*
53+
* @param player The [Player] to get the progress from.
54+
* @param tickIntervalMs The granularity of the progress updates in milliseconds. A smaller value
55+
* means more frequent updates, which may cause more recompositions.
56+
* @param scope The [CoroutineScope] to use for listening to player progress updates.
57+
* @param content A content lambda that receives a [ProgressStateWithTickInterval] as its receiver
58+
* scope, which can be used to build a custom UI.
59+
*/
60+
@UnstableApi
61+
@Composable
62+
fun TimeText(
63+
player: Player,
64+
@IntRange(from = 0) tickIntervalMs: Int = 1000,
65+
scope: CoroutineScope = rememberCoroutineScope(),
66+
content: @Composable ProgressStateWithTickInterval.() -> Unit,
67+
) {
68+
rememberProgressStateWithTickInterval(player, tickIntervalMs = tickIntervalMs.toLong(), scope)
69+
.content()
70+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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.material3.indicator
18+
19+
import androidx.compose.foundation.text.BasicText
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.rememberCoroutineScope
22+
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.text.TextStyle
24+
import androidx.media3.common.C
25+
import androidx.media3.common.Player
26+
import androidx.media3.common.util.UnstableApi
27+
import androidx.media3.common.util.Util.getStringForTime
28+
import androidx.media3.ui.compose.indicators.TimeText
29+
import androidx.media3.ui.compose.state.ProgressStateWithTickInterval
30+
import kotlinx.coroutines.CoroutineScope
31+
32+
/**
33+
* A composable that displays the current position of the player.
34+
*
35+
* @param player The [Player] to get the position from.
36+
* @param modifier The [Modifier] to be applied to the text.
37+
* @param scope The [CoroutineScope] to use for listening to player progress updates.
38+
*/
39+
@UnstableApi
40+
@Composable
41+
fun PositionText(
42+
player: Player,
43+
modifier: Modifier = Modifier,
44+
scope: CoroutineScope = rememberCoroutineScope(),
45+
) {
46+
TimeText(player, modifier, TimeFormat.position(), scope)
47+
}
48+
49+
/**
50+
* A composable that displays the duration of the media.
51+
*
52+
* @param player The [Player] to get the duration from.
53+
* @param modifier The [Modifier] to be applied to the text.
54+
* @param scope The [CoroutineScope] to use for listening to player progress updates.
55+
*/
56+
@UnstableApi
57+
@Composable
58+
fun DurationText(
59+
player: Player,
60+
modifier: Modifier = Modifier,
61+
scope: CoroutineScope = rememberCoroutineScope(),
62+
) {
63+
TimeText(player, modifier, TimeFormat.duration(), scope)
64+
}
65+
66+
/**
67+
* A composable that displays the duration of the media.
68+
*
69+
* @param player The [Player] to get the duration from.
70+
* @param modifier The [Modifier] to be applied to the text.
71+
* @param showNegative Whether to display the remaining time with a minus sign.
72+
* @param scope The [CoroutineScope] to use for listening to player progress updates.
73+
*/
74+
@UnstableApi
75+
@Composable
76+
fun RemainingDurationText(
77+
player: Player,
78+
modifier: Modifier = Modifier,
79+
showNegative: Boolean = false,
80+
scope: CoroutineScope = rememberCoroutineScope(),
81+
) {
82+
TimeText(player, modifier, TimeFormat.remaining(showNegative), scope)
83+
}
84+
85+
/**
86+
* A composable that displays the duration of the media.
87+
*
88+
* @param player The [Player] to get the duration from.
89+
* @param modifier The [Modifier] to be applied to the text.
90+
* @param separator The separator string to be used between the current position and duration.
91+
* @param scope The [CoroutineScope] to use for listening to player progress updates.
92+
*/
93+
@UnstableApi
94+
@Composable
95+
fun PositionAndDurationText(
96+
player: Player,
97+
modifier: Modifier = Modifier,
98+
separator: String = " / ",
99+
scope: CoroutineScope = rememberCoroutineScope(),
100+
) {
101+
TimeText(player, modifier, TimeFormat.positionAndDuration(separator), scope)
102+
}
103+
104+
/**
105+
* Progress indicator that represents the [Player's][Player] progress state in textual form.
106+
*
107+
* It displays the up-to-date current position and duration of the media, formatted by
108+
* [getStringForTime].
109+
*
110+
* @param player The [Player] to get the progress from.
111+
* @param modifier The [Modifier] to be applied to the text.
112+
* @param timeFormat The [TimeFormat] to use for displaying the time.
113+
* @param scope Coroutine scope to listen to the progress updates from the player.
114+
*/
115+
@UnstableApi
116+
@Composable
117+
fun TimeText(
118+
player: Player,
119+
modifier: Modifier = Modifier,
120+
timeFormat: TimeFormat,
121+
scope: CoroutineScope = rememberCoroutineScope(),
122+
) {
123+
TimeText(player, scope = scope) { TimeText(state = this, timeFormat, modifier) }
124+
}
125+
126+
@Composable
127+
private fun TimeText(
128+
state: ProgressStateWithTickInterval,
129+
timeFormat: TimeFormat,
130+
modifier: Modifier,
131+
) {
132+
val text =
133+
when (timeFormat.format) {
134+
TimeFormat.POSITION -> getStringForTime(state.currentPositionMs)
135+
TimeFormat.DURATION -> getStringForTime(state.durationMs)
136+
TimeFormat.REMAINING ->
137+
if (state.durationMs != C.TIME_UNSET) {
138+
val remainingMs =
139+
if (timeFormat.showNegative) {
140+
state.currentPositionMs - state.durationMs
141+
} else {
142+
state.durationMs - state.currentPositionMs
143+
}
144+
getStringForTime(remainingMs)
145+
} else {
146+
getStringForTime(C.TIME_UNSET)
147+
}
148+
149+
TimeFormat.POSITION_AND_DURATION ->
150+
"${getStringForTime(state.currentPositionMs)}${timeFormat.separator}${getStringForTime(state.durationMs)}"
151+
152+
else -> throw IllegalStateException("Unrecognized TimeFormat ${timeFormat.format}")
153+
}
154+
BasicText(text, modifier, style = TextStyle(fontFeatureSettings = "tnum"))
155+
}
156+
157+
/**
158+
* A class for specifying the format of the time to be displayed by [TimeText].
159+
*
160+
* Instances of this class can be created using the factory methods, such as [position], [duration],
161+
* [remaining], and [positionAndDuration].
162+
*/
163+
@UnstableApi
164+
class TimeFormat
165+
private constructor(
166+
internal val format: Int,
167+
internal val separator: String?,
168+
internal val showNegative: Boolean,
169+
) {
170+
companion object {
171+
internal const val POSITION = 0
172+
internal const val DURATION = 1
173+
internal const val REMAINING = 2
174+
internal const val POSITION_AND_DURATION = 3
175+
176+
/** Creates a [TimeFormat] that displays the current position of the player. */
177+
fun position(): TimeFormat = TimeFormat(POSITION, null, false)
178+
179+
/** Creates a [TimeFormat] that displays the total duration of the media. */
180+
fun duration(): TimeFormat = TimeFormat(DURATION, null, false)
181+
182+
/**
183+
* Creates a [TimeFormat] that displays the remaining time of the media.
184+
*
185+
* @param showNegative Whether to display the remaining time with a minus sign.
186+
*/
187+
fun remaining(showNegative: Boolean = false): TimeFormat =
188+
TimeFormat(REMAINING, null, showNegative)
189+
190+
/**
191+
* Creates a [TimeFormat] that displays both the current position and the total duration.
192+
*
193+
* @param separator The separator string to be used between the current position and duration.
194+
*/
195+
fun positionAndDuration(separator: String = " / "): TimeFormat =
196+
TimeFormat(POSITION_AND_DURATION, separator, false)
197+
}
198+
199+
override fun equals(other: Any?): Boolean {
200+
if (this === other) return true
201+
if (other !is TimeFormat) return false
202+
return format == other.format &&
203+
separator == other.separator &&
204+
showNegative == other.showNegative
205+
}
206+
207+
override fun hashCode(): Int {
208+
var result = format.hashCode()
209+
result = 31 * result + (separator?.hashCode() ?: 0)
210+
result = 31 * result + showNegative.hashCode()
211+
return result
212+
}
213+
}

0 commit comments

Comments
 (0)