Merge pull request #6830 from element-hq/feature/bma/a11y/videoPlayer

[a11y] Improve accessibility of video and audio player
This commit is contained in:
Benoit Marty 2026-05-20 18:06:04 +02:00 committed by GitHub
commit aa3427381c
4 changed files with 56 additions and 13 deletions

View file

@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api) implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.voiceplayer.api) implementation(projects.libraries.voiceplayer.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)

View file

@ -28,6 +28,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -100,36 +103,54 @@ fun MediaPlayerControllerView(
contentColor = ElementTheme.colors.iconOnSolidPrimary, contentColor = ElementTheme.colors.iconOnSolidPrimary,
) )
} }
val a11yPause = stringResource(CommonStrings.a11y_pause)
val a11yPlay = stringResource(CommonStrings.a11y_play)
IconButton( IconButton(
modifier = Modifier modifier = Modifier
.size(36.dp), .size(36.dp)
.semantics {
stateDescription = if (state.isPlaying) a11yPause else a11yPlay
},
onClick = onTogglePlay, onClick = onTogglePlay,
colors = colors, colors = colors,
) { ) {
if (state.isPlaying) { if (state.isPlaying) {
Icon( Icon(
imageVector = CompoundIcons.PauseSolid(), imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(CommonStrings.a11y_pause) contentDescription = null,
) )
} else { } else {
Icon( Icon(
imageVector = CompoundIcons.PlaySolid(), imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(CommonStrings.a11y_play) contentDescription = null,
) )
} }
} }
val position = state.displayProgressInMillis.toHumanReadableDuration()
val a11yPosition = stringResource(CommonStrings.a11y_position, position)
Text( Text(
modifier = Modifier modifier = Modifier
.widthIn(min = 48.dp) .widthIn(min = 48.dp)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp)
text = state.displayProgressInMillis.toHumanReadableDuration(), .semantics {
contentDescription = a11yPosition
},
text = position,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium, style = ElementTheme.typography.fontBodyXsMedium,
) )
var lastSelectedValue by remember { mutableFloatStateOf(-1f) } var lastSelectedValue by remember { mutableFloatStateOf(-1f) }
Slider( Slider(
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.semantics {
// Speak out a progress percent instead of milliseconds
stateDescription = buildString {
append((state.progressAsFloat * 100).toInt())
append("%")
}
},
valueRange = 0f..state.durationInMillis.toFloat(), valueRange = 0f..state.durationInMillis.toFloat(),
value = lastSelectedValue.takeIf { it >= 0 } value = lastSelectedValue.takeIf { it >= 0 }
?: state.seekingToMillis?.toFloat() ?: state.seekingToMillis?.toFloat()
@ -146,30 +167,40 @@ fun MediaPlayerControllerView(
val formattedDuration = remember(state.durationInMillis) { val formattedDuration = remember(state.durationInMillis) {
state.durationInMillis.toHumanReadableDuration() state.durationInMillis.toHumanReadableDuration()
} }
val a11yDuration = stringResource(CommonStrings.a11y_duration, formattedDuration)
Text( Text(
modifier = Modifier modifier = Modifier
.widthIn(min = 48.dp) .widthIn(min = 48.dp)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp)
.semantics {
contentDescription = a11yDuration
},
text = formattedDuration, text = formattedDuration,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium, style = ElementTheme.typography.fontBodyXsMedium,
) )
if (state.canMute) { if (state.canMute) {
val a11yUnmute = stringResource(CommonStrings.common_unmute)
val a11yMute = stringResource(CommonStrings.common_mute)
IconButton( IconButton(
onClick = onToggleMute, onClick = onToggleMute,
modifier = Modifier
.semantics {
stateDescription = if (state.isMuted) a11yUnmute else a11yMute
},
) { ) {
if (state.isMuted) { if (state.isMuted) {
Icon( Icon(
imageVector = CompoundIcons.VolumeOffSolid(), imageVector = CompoundIcons.VolumeOffSolid(),
tint = ElementTheme.colors.iconPrimary, tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_unmute) contentDescription = null,
) )
} else { } else {
Icon( Icon(
imageVector = CompoundIcons.VolumeOnSolid(), imageVector = CompoundIcons.VolumeOnSolid(),
tint = ElementTheme.colors.iconPrimary, tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_mute) contentDescription = null,
) )
} }
} }

View file

@ -57,6 +57,7 @@ import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPla
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.zoomable import me.saket.telephoto.zoomable.zoomable
import timber.log.Timber import timber.log.Timber
@ -162,12 +163,20 @@ private fun ExoPlayerMediaVideoView(
var autoHideController by remember { mutableIntStateOf(0) } var autoHideController by remember { mutableIntStateOf(0) }
LaunchedEffect(autoHideController) { val isTalkbackActive = isTalkbackActive()
delay(5.seconds) LaunchedEffect(autoHideController, isTalkbackActive) {
if (exoPlayer.isPlaying) { if (isTalkbackActive) {
// Ensure that the controller is always visible when talkback is active
mediaPlayerControllerState = mediaPlayerControllerState.copy( mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = false, isVisible = true,
) )
} else {
delay(5.seconds)
if (exoPlayer.isPlaying) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = false,
)
}
} }
} }

View file

@ -9,6 +9,7 @@
<item quantity="one">"%1$d digit entered"</item> <item quantity="one">"%1$d digit entered"</item>
<item quantity="other">"%1$d digits entered"</item> <item quantity="other">"%1$d digits entered"</item>
</plurals> </plurals>
<string name="a11y_duration">"Duration: %1$s"</string>
<string name="a11y_edit_avatar">"Edit avatar"</string> <string name="a11y_edit_avatar">"Edit avatar"</string>
<string name="a11y_edit_room_address_hint">"The full address will be %1$s"</string> <string name="a11y_edit_room_address_hint">"The full address will be %1$s"</string>
<string name="a11y_encryption_details">"Encryption details"</string> <string name="a11y_encryption_details">"Encryption details"</string>
@ -33,6 +34,7 @@
<string name="a11y_playback_speed">"Playback speed"</string> <string name="a11y_playback_speed">"Playback speed"</string>
<string name="a11y_poll">"Poll"</string> <string name="a11y_poll">"Poll"</string>
<string name="a11y_poll_end">"Ended poll"</string> <string name="a11y_poll_end">"Ended poll"</string>
<string name="a11y_position">"Position: %1$s"</string>
<string name="a11y_qr_code">"QR Code"</string> <string name="a11y_qr_code">"QR Code"</string>
<string name="a11y_react_with">"React with %1$s"</string> <string name="a11y_react_with">"React with %1$s"</string>
<string name="a11y_react_with_other_emojis">"React with other emojis"</string> <string name="a11y_react_with_other_emojis">"React with other emojis"</string>