Skip to content

Commit ed230e0

Browse files
committed
feat: make player controls screen reader accessible
1 parent 7dcc745 commit ed230e0

8 files changed

Lines changed: 81 additions & 8 deletions

File tree

‎app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerTimeBarGestureTest.kt‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import androidx.compose.ui.test.down
1111
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
1212
import androidx.compose.ui.test.moveTo
1313
import androidx.compose.ui.test.onNodeWithTag
14+
import androidx.compose.ui.test.performSemanticsAction
1415
import androidx.compose.ui.test.performTouchInput
16+
import androidx.compose.ui.semantics.SemanticsActions
1517
import androidx.compose.ui.platform.testTag
1618
import androidx.compose.ui.unit.dp
1719
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -66,6 +68,22 @@ class PlayerTimeBarGestureTest {
6668
}
6769
}
6870

71+
@Test
72+
fun accessibilityActionCommitsRequestedPosition() {
73+
val scrubbed = mutableListOf<Long>()
74+
val committed = mutableListOf<Long>()
75+
showTimeline(scrubbed::add, committed::add) {}
76+
77+
composeRule.onNodeWithTag(TIMELINE_TAG).performSemanticsAction(
78+
SemanticsActions.SetProgress,
79+
) { setProgress -> setProgress(45_000f) }
80+
81+
composeRule.runOnIdle {
82+
assertEquals(45_000L, scrubbed.single())
83+
assertEquals(45_000L, committed.single())
84+
}
85+
}
86+
6987
private fun showTimeline(
7088
onScrub: (Long) -> Unit,
7189
onScrubFinished: (Long) -> Unit,
@@ -81,6 +99,8 @@ class PlayerTimeBarGestureTest {
8199
onScrub = onScrub,
82100
onScrubFinished = onScrubFinished,
83101
onScrubCancelled = onScrubCancelled,
102+
accessibilityLabel = "Playback position",
103+
accessibilityStateDescription = "0:10 of 1:00",
84104
modifier = Modifier
85105
.size(width = 240.dp, height = 36.dp)
86106
.testTag(TIMELINE_TAG),

‎app/src/main/java/dev/typetype/android/AppNavHost.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) {
6666
val serverBaseUrl by mainViewModel.currentServerBaseUrl.collectAsStateWithLifecycle()
6767
val currentServerId by mainViewModel.currentServerId.collectAsStateWithLifecycle()
6868
val currentProfile by mainViewModel.currentProfile.collectAsStateWithLifecycle()
69+
val appPreferences by mainViewModel.preferences.collectAsStateWithLifecycle()
6970
val notificationBadge by rememberNotificationBadge()
7071
val deArrowViewModel = hiltViewModel<DeArrowBrandingViewModel>()
7172
val playerViewModel = hiltViewModel<PlayerViewModel>()
@@ -141,6 +142,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) {
141142
avatarUrl = avatarUrl,
142143
avatarFallbackLetter = avatarFallback,
143144
showShorts = !deArrowSettings.hideShorts,
145+
accessiblePlayerControls = appPreferences.playerAccessibleControlsEnabled,
144146
onPlayVideo = onPlayVideo,
145147
onOpenChannel = onOpenChannel,
146148
onClosePlayback = mainViewModel::closePlayback,

‎app/src/main/java/dev/typetype/android/AppShell.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ fun AppShell(
5959
avatarUrl: String? = null,
6060
avatarFallbackLetter: String? = null,
6161
showShorts: Boolean = true,
62+
accessiblePlayerControls: Boolean = false,
6263
onPlayVideo: (videoUrl: String) -> Unit,
6364
onOpenChannel: (channelUrl: String) -> Unit,
6465
onClosePlayback: () -> Unit,
@@ -184,6 +185,7 @@ fun AppShell(
184185
isFullscreen = isPlayerFullscreen,
185186
onFullscreenChange = { isPlayerFullscreen = it },
186187
mediaController = mediaController,
188+
accessibleControlsEnabled = accessiblePlayerControls,
187189
onOpenChannel = onOpenChannel,
188190
onOpenAccounts = onOpenAccounts,
189191
onClosePlayback = onClosePlayback,

‎app/src/main/java/dev/typetype/android/feature/player/ShortsPlaybackProgress.kt‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ import androidx.compose.runtime.mutableStateOf
99
import androidx.compose.runtime.remember
1010
import androidx.compose.runtime.setValue
1111
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.res.stringResource
1213
import androidx.compose.ui.unit.dp
1314
import androidx.media3.common.Player
1415
import androidx.media3.common.util.UnstableApi
1516
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
17+
import dev.typetype.android.R
1618
import dev.typetype.android.feature.player.components.TimelineTrack
19+
import dev.typetype.android.feature.player.components.formatPlayerTime
1720

1821
@OptIn(markerClass = [UnstableApi::class])
1922
@Composable
@@ -25,8 +28,9 @@ internal fun ShortsPlaybackProgress(
2528
val progress = rememberProgressStateWithTickInterval(player, SHORTS_PROGRESS_TICK_MS)
2629
val durationMs = progress.durationMs.takeIf { it > 0L } ?: fallbackDurationMs.coerceAtLeast(0L)
2730
var scrubPositionMs by remember { mutableStateOf<Long?>(null) }
31+
val positionMs = scrubPositionMs ?: progress.currentPositionMs
2832
TimelineTrack(
29-
positionMs = scrubPositionMs ?: progress.currentPositionMs,
33+
positionMs = positionMs,
3034
durationMs = durationMs,
3135
segments = emptyList(),
3236
compact = false,
@@ -36,6 +40,12 @@ internal fun ShortsPlaybackProgress(
3640
scrubPositionMs = null
3741
},
3842
onScrubCancelled = { scrubPositionMs = null },
43+
accessibilityLabel = stringResource(R.string.player_timeline),
44+
accessibilityStateDescription = stringResource(
45+
R.string.player_timeline_position,
46+
formatPlayerTime(positionMs),
47+
formatPlayerTime(durationMs),
48+
),
3949
modifier = modifier.fillMaxWidth().height(36.dp),
4050
)
4151
}

‎app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ internal fun PlayerCenterControls(
4949
)
5050
PlayerCenterButton(
5151
iconRes = if (playPauseState.showPlay) R.drawable.ic_play else R.drawable.ic_pause,
52-
contentDescription = stringResource(R.string.player_play_pause),
52+
contentDescription = stringResource(
53+
if (playPauseState.showPlay) R.string.player_play else R.string.player_pause,
54+
),
5355
enabled = playPauseState.isEnabled,
5456
onClick = { playPauseState.onClick() },
5557
buttonSize = if (isFullscreen) 74.dp else 62.dp,

‎app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,20 @@ import androidx.compose.ui.geometry.Offset
2626
import androidx.compose.ui.geometry.Size
2727
import androidx.compose.ui.graphics.Color
2828
import androidx.compose.ui.input.pointer.pointerInput
29+
import androidx.compose.ui.semantics.ProgressBarRangeInfo
30+
import androidx.compose.ui.semantics.contentDescription
31+
import androidx.compose.ui.semantics.progressBarRangeInfo
32+
import androidx.compose.ui.semantics.setProgress
33+
import androidx.compose.ui.semantics.stateDescription
34+
import androidx.compose.ui.semantics.semantics
35+
import androidx.compose.ui.res.stringResource
2936
import androidx.compose.ui.text.style.TextAlign
3037
import androidx.compose.ui.unit.dp
3138
import androidx.compose.ui.unit.sp
3239
import androidx.media3.common.Player
3340
import androidx.media3.common.util.UnstableApi
3441
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
42+
import dev.typetype.android.R
3543
import dev.typetype.android.domain.stream.SponsorBlockSegment
3644
import kotlin.time.Duration.Companion.milliseconds
3745

@@ -60,14 +68,16 @@ fun PlayerTimeBar(
6068

6169
val durationMs = progressState.durationMs.coerceAtLeast(0L)
6270
val displayedPosMs = scrubPositionMs ?: progressState.currentPositionMs.coerceIn(0L, durationMs)
71+
val positionLabel = formatPlayerTime(displayedPosMs)
72+
val durationLabel = formatPlayerTime(durationMs)
6373

6474
Row(
6575
modifier = modifier,
6676
verticalAlignment = Alignment.CenterVertically,
6777
horizontalArrangement = Arrangement.spacedBy(8.dp),
6878
) {
6979
Text(
70-
text = formatTime(displayedPosMs),
80+
text = positionLabel,
7181
style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp),
7282
color = Color.White,
7383
modifier = if (compact) {
@@ -88,13 +98,19 @@ fun PlayerTimeBar(
8898
scrubPositionMs = null
8999
},
90100
onScrubCancelled = { scrubPositionMs = null },
101+
accessibilityLabel = stringResource(R.string.player_timeline),
102+
accessibilityStateDescription = stringResource(
103+
R.string.player_timeline_position,
104+
positionLabel,
105+
durationLabel,
106+
),
91107
modifier = Modifier
92108
.weight(1f)
93109
.padding(horizontal = if (compact) 2.dp else 4.dp)
94110
.height(if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT),
95111
)
96112
Text(
97-
text = formatTime(durationMs),
113+
text = durationLabel,
98114
style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp),
99115
color = Color.White.copy(alpha = 0.7f),
100116
modifier = if (compact) {
@@ -115,12 +131,28 @@ internal fun TimelineTrack(
115131
onScrub: (Long) -> Unit,
116132
onScrubFinished: (Long) -> Unit,
117133
onScrubCancelled: () -> Unit,
134+
accessibilityLabel: String,
135+
accessibilityStateDescription: String,
118136
modifier: Modifier = Modifier,
119137
) {
120138
val activeColor = MaterialTheme.colorScheme.primary
121139
val inactiveColor = Color.White.copy(alpha = 0.3f)
122140
Box(
123141
modifier = modifier
142+
.semantics {
143+
contentDescription = accessibilityLabel
144+
stateDescription = accessibilityStateDescription
145+
progressBarRangeInfo = ProgressBarRangeInfo(
146+
current = positionMs.toFloat(),
147+
range = 0f..durationMs.coerceAtLeast(1L).toFloat(),
148+
)
149+
setProgress { target ->
150+
val targetMs = target.toLong().coerceIn(0L, durationMs.coerceAtLeast(0L))
151+
onScrub(targetMs)
152+
onScrubFinished(targetMs)
153+
true
154+
}
155+
}
124156
.pointerInput(durationMs) {
125157
detectTapGestures { offset ->
126158
val targetMs = offset.x.toPositionMs(size.width.toFloat(), durationMs)
@@ -207,7 +239,7 @@ private fun Float.toPositionMs(width: Float, durationMs: Long): Long {
207239
return ((this / width).coerceIn(0f, 1f) * durationMs).toLong()
208240
}
209241

210-
private fun formatTime(ms: Long): String {
242+
internal fun formatPlayerTime(ms: Long): String {
211243
val total = ms.milliseconds.inWholeSeconds
212244
val h = total / 3600
213245
val m = (total % 3600) / 60

‎app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,14 @@ fun PlayerHost(
4040
onOpenChannel: (channelUrl: String) -> Unit,
4141
onOpenAccounts: () -> Unit,
4242
onClosePlayback: () -> Unit,
43+
accessibleControlsEnabled: Boolean = false,
4344
onTransitionProgressChange: (Float) -> Unit = {},
4445
content: @Composable () -> Unit,
4546
) {
4647
val state by controller.state.collectAsStateWithLifecycle()
4748
val density = LocalDensity.current
4849
val isInPip by rememberIsInPipMode()
49-
val touchExplorationEnabled = rememberAccessiblePlayerControls(false)
50+
val accessibleControls = rememberAccessiblePlayerControls(accessibleControlsEnabled)
5051
val hapticFeedback = LocalHapticFeedback.current
5152
val activity = LocalActivity.current
5253
val configuration = LocalConfiguration.current
@@ -125,7 +126,7 @@ fun PlayerHost(
125126
miniAnchorPx = miniAnchorPx,
126127
containerHeightPx = containerHeightPx,
127128
miniHeightPx = miniHeightPx,
128-
dragEnabled = !isFullscreen && !isInPip && !touchExplorationEnabled,
129+
dragEnabled = !isFullscreen && !isInPip && !accessibleControls,
129130
miniContentEnabled = !isInPip,
130131
onTargetSettled = { target ->
131132
when (target) {

‎app/src/main/res/values/strings.xml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
<string name="shorts_title_copied">Title copied</string>
6464
<string name="player_rewind">Rewind</string>
6565
<string name="player_play_pause">Play / pause</string>
66+
<string name="player_play">Play</string>
67+
<string name="player_pause">Pause</string>
6668
<plurals name="player_autoplay_up_next">
6769
<item quantity="one">Up next in %1$d second</item>
6870
<item quantity="other">Up next in %1$d seconds</item>
@@ -80,6 +82,8 @@
8082
<string name="player_state_paused">Paused</string>
8183
<string name="player_forward">Forward</string>
8284
<string name="player_fullscreen">Fullscreen</string>
85+
<string name="player_timeline">Playback position</string>
86+
<string name="player_timeline_position">%1$s of %2$s</string>
8387
<string name="player_back">Back</string>
8488
<string name="player_playback_options">Playback options</string>
8589
<string name="playback_options_codec">Codec</string>
@@ -227,7 +231,7 @@
227231
<string name="settings_about_subtitle">Version and credits</string>
228232
<string name="settings_player_section_gestures">Gestures</string>
229233
<string name="settings_player_accessible_controls">Accessible player controls</string>
230-
<string name="settings_player_accessible_controls_subtitle">Keep controls visible and replace gesture-only actions with buttons</string>
234+
<string name="settings_player_accessible_controls_subtitle">Keep controls visible and disable gesture-only interactions. Enabled automatically with screen readers.</string>
231235
<string name="settings_player_double_tap_seek">Double-tap to seek</string>
232236
<string name="settings_player_double_tap_seek_subtitle">Double-tap left or right to seek, or center to play and pause</string>
233237
<string name="settings_player_double_tap_seek_time">Double-tap seek time</string>

0 commit comments

Comments
 (0)