Configure playback controls for RTL (#1945)

## Description
Reverts/fixes parts of #1404 for the playback UI. Generally the playback
UI always uses left-to-right for seek bar & media controls even on RTL.
Other buttons or texts will be RTL.

### Related issues
Fixes #1397 

### Testing
Emulator w/ forced RTL English

## Screenshots
See
https://github.com/damontecres/Wholphin/issues/1397#issuecomment-5553711593

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-09-08 22:51:54 -04:00 • committed by GitHub
parent 4a567cbcf4
commit 0b995b5404
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 214 deletions

View file

@ -45,9 +45,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@ -106,11 +104,9 @@ fun NowPlayingPage(
).value.appPreferences ).value.appPreferences
val musicPrefs = preferences.musicPreferences val musicPrefs = preferences.musicPreferences
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val keyHandler = val keyHandler =
remember(isLtr, preferences) { remember(preferences) {
PlaybackKeyHandler( PlaybackKeyHandler(
isLtr = isLtr,
player = player, player = player,
controlsEnabled = true, controlsEnabled = true,
skipWithLeftRight = false, skipWithLeftRight = false,

View file

@ -16,7 +16,6 @@ import kotlin.time.Duration
* Handles [KeyEvent]s during playback on [PlaybackPage] * Handles [KeyEvent]s during playback on [PlaybackPage]
*/ */
class PlaybackKeyHandler( class PlaybackKeyHandler(
private val isLtr: Boolean,
private val player: Player, private val player: Player,
private val controlsEnabled: Boolean, private val controlsEnabled: Boolean,
private val skipWithLeftRight: Boolean, private val skipWithLeftRight: Boolean,
@ -53,17 +52,9 @@ class PlaybackKeyHandler(
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) { if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && isSkipBack(it)) { if (skipWithLeftRight && isSkipBack(it)) {
if (isLtr) { seekBy(-seekBack)
seekBy(-seekBack)
} else {
seekBy(seekForward)
}
} else if (skipWithLeftRight && isSkipForward(it)) { } else if (skipWithLeftRight && isSkipForward(it)) {
if (isLtr) { seekBy(seekForward)
seekBy(seekForward)
} else {
seekBy(-seekBack)
}
} else if (isEnterKey(it) && isDpadSeekVisible()) { } else if (isEnterKey(it) && isDpadSeekVisible()) {
// If d-pad seek bar is visible, hide it // If d-pad seek bar is visible, hide it
clearSkipIndicator.invoke() clearSkipIndicator.invoke()
@ -142,7 +133,7 @@ class PlaybackKeyHandler(
return false return false
} }
val isBack = if (isLtr) isSkipBack(event) else isSkipForward(event) val isBack = isSkipBack(event)
return when (event.type) { return when (event.type) {
KeyEventType.KeyDown -> { KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount val repeatCount = event.nativeKeyEvent.repeatCount

View file

@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -243,11 +244,9 @@ fun PlaybackPageContent(
) )
seekBarState.onValueChange(skipPosition) seekBarState.onValueChange(skipPosition)
} }
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val keyHandler = val keyHandler =
remember(isLtr, preferences) { remember(preferences) {
PlaybackKeyHandler( PlaybackKeyHandler(
isLtr = isLtr,
player = player, player = player,
controlsEnabled = state.nextUp == null, controlsEnabled = state.nextUp == null,
skipWithLeftRight = true, skipWithLeftRight = true,
@ -390,17 +389,19 @@ fun PlaybackPageContent(
Modifier Modifier
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) { ) {
DpadSeekOverlay( CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
player = player, DpadSeekOverlay(
seekPositionMs = skipPosition, player = player,
trickplayInfo = state.currentMediaInfo.trickPlayInfo, seekPositionMs = skipPosition,
trickplayUrlFor = viewModel::getTrickplayUrl, trickplayInfo = state.currentMediaInfo.trickPlayInfo,
modifier = trickplayUrlFor = viewModel::getTrickplayUrl,
Modifier modifier =
.align(Alignment.BottomCenter) Modifier
.padding(bottom = 16.dp) .align(Alignment.BottomCenter)
.fillMaxWidth(.95f), .padding(bottom = 16.dp)
) .fillMaxWidth(.95f),
)
}
// Clear the overlay after a delay // Clear the overlay after a delay
LaunchedEffect(skipIndicatorDuration) { LaunchedEffect(skipIndicatorDuration) {
delay(1.5.seconds) delay(1.5.seconds)
@ -426,15 +427,23 @@ fun PlaybackPageContent(
// Show a small progress bar along the bottom of the screen // Show a small progress bar along the bottom of the screen
if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) { if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) {
val percent = skipPosition.toFloat() / player.duration.toFloat() val percent = skipPosition.toFloat() / player.duration.toFloat()
Box( CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
modifier = Box(
Modifier Modifier
.align(Alignment.BottomStart) .fillMaxWidth()
.background(MaterialTheme.colorScheme.border) .align(Alignment.BottomCenter),
.clip(RectangleShape) ) {
.height(3.dp) Box(
.fillMaxWidth(percent), modifier =
) Modifier
.align(Alignment.BottomStart)
.background(MaterialTheme.colorScheme.border)
.clip(RectangleShape)
.height(3.dp)
.fillMaxWidth(percent),
)
}
}
} }
} }

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@ -191,6 +192,7 @@ fun PlaybackControls(
onClickPlaybackDialogType = onClickPlaybackDialogType, onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier.align(Alignment.CenterStart), modifier = Modifier.align(Alignment.CenterStart),
) )
PlaybackButtons( PlaybackButtons(
player = player, player = player,
initialFocusRequester = initialFocusRequester, initialFocusRequester = initialFocusRequester,
@ -204,6 +206,7 @@ fun PlaybackControls(
skipBackOnResume = skipBackOnResume, skipBackOnResume = skipBackOnResume,
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
Row( Row(
modifier = Modifier.align(Alignment.CenterEnd), modifier = Modifier.align(Alignment.CenterEnd),
) { ) {
@ -286,31 +289,40 @@ fun SeekTimecodes(
durationMs: Long, durationMs: Long,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( val currentLayoutDirection = LocalLayoutDirection.current
modifier = modifier, CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
horizontalArrangement = Arrangement.SpaceBetween, Row(
) { modifier = modifier,
val resources = LocalResources.current horizontalArrangement = Arrangement.SpaceBetween,
val positionSec = positionMs / 1000 ) {
val remainingSec = (durationMs - positionMs) / 1000 val resources = LocalResources.current
val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) } val positionSec = positionMs / 1000
val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } val remainingSec = (durationMs - positionMs) / 1000
Text( val positionText =
text = positionText, remember(positionSec) { resources.formatDuration(positionSec.seconds) }
color = MaterialTheme.colorScheme.onSurface, val remainingText =
style = MaterialTheme.typography.labelLarge, remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" }
modifier = CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) {
Modifier Text(
.padding(8.dp), text = positionText,
) color = MaterialTheme.colorScheme.onSurface,
Text( style = MaterialTheme.typography.labelLarge,
text = remainingText, modifier =
color = MaterialTheme.colorScheme.onSurface, Modifier
style = MaterialTheme.typography.labelLarge, .padding(8.dp),
modifier = )
Modifier }
.padding(8.dp), CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) {
) Text(
text = remainingText,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(8.dp),
)
}
}
} }
} }
@ -389,61 +401,62 @@ fun PlaybackButtons(
seekForward: Duration, seekForward: Duration,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row( Row(
modifier = modifier.focusGroup(), modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing), horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) { ) {
PlaybackButton( PlaybackButton(
iconRes = if (isLtr) R.drawable.baseline_skip_previous_24 else R.drawable.baseline_skip_next_24, iconRes = R.drawable.baseline_skip_previous_24,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Previous) onPlaybackActionClick.invoke(PlaybackAction.Previous)
}, },
enabled = previousEnabled, enabled = previousEnabled,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
) )
PlaybackButton( PlaybackButton(
iconRes = if (isLtr) R.drawable.baseline_fast_rewind_24 else R.drawable.baseline_fast_forward_24, iconRes = R.drawable.baseline_fast_rewind_24,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
player.seekBack(seekBack) player.seekBack(seekBack)
}, },
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
) )
PlaybackButton( PlaybackButton(
modifier = Modifier.focusRequester(initialFocusRequester), modifier = Modifier.focusRequester(initialFocusRequester),
iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
if (showPlay) { if (showPlay) {
player.play() player.play()
skipBackOnResume?.let { skipBackOnResume?.let {
player.seekBack(it) player.seekBack(it)
}
} else {
player.pause()
} }
} else { },
player.pause() onControllerInteraction = onControllerInteraction,
} )
}, PlaybackButton(
onControllerInteraction = onControllerInteraction, iconRes = R.drawable.baseline_fast_forward_24,
) onClick = {
PlaybackButton( onControllerInteraction.invoke()
iconRes = if (isLtr) R.drawable.baseline_fast_forward_24 else R.drawable.baseline_fast_rewind_24, player.seekForward(seekForward)
onClick = { },
onControllerInteraction.invoke() onControllerInteraction = onControllerInteraction,
player.seekForward(seekForward) )
}, PlaybackButton(
onControllerInteraction = onControllerInteraction, iconRes = R.drawable.baseline_skip_next_24,
) onClick = {
PlaybackButton( onControllerInteraction.invoke()
iconRes = if (isLtr) R.drawable.baseline_skip_next_24 else R.drawable.baseline_skip_previous_24, onPlaybackActionClick.invoke(PlaybackAction.Next)
onClick = { },
onControllerInteraction.invoke() enabled = nextEnabled,
onPlaybackActionClick.invoke(PlaybackAction.Next) onControllerInteraction = onControllerInteraction,
}, )
enabled = nextEnabled, }
onControllerInteraction = onControllerInteraction,
)
} }
} }

View file

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
@ -40,8 +41,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.media3.common.Player import androidx.media3.common.Player
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -254,39 +257,41 @@ fun PlaybackOverlay(
} }
} }
// Trickplay CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
AnimatedVisibility( // Trickplay
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, AnimatedVisibility(
enter = visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
expandVertically( enter =
spring( expandVertically(
stiffness = Spring.StiffnessMedium, spring(
visibilityThreshold = IntSize.VisibilityThreshold, stiffness = Spring.StiffnessMedium,
), visibilityThreshold = IntSize.VisibilityThreshold,
) + fadeIn(), ),
exit = shrinkVertically() + fadeOut(), ) + fadeIn(),
) { exit = shrinkVertically() + fadeOut(),
Box(
modifier =
Modifier
.align(Alignment.Center)
.fillMaxWidth(.95f),
) { ) {
Column( Box(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.Center)
.offsetByPercent( .fillMaxWidth(.95f),
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
) { ) {
TrickplayPreview( Column(
seekProgressMs = seekProgressMs, verticalArrangement = Arrangement.spacedBy(8.dp),
trickplayInfo = trickplayInfo, horizontalAlignment = Alignment.CenterHorizontally,
trickplayUrlFor = trickplayUrlFor, modifier =
) Modifier
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
) {
TrickplayPreview(
seekProgressMs = seekProgressMs,
trickplayInfo = trickplayInfo,
trickplayUrlFor = trickplayUrlFor,
)
}
} }
} }
} }

View file

@ -43,8 +43,6 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.ControllerViewState
@ -202,7 +200,6 @@ private fun SeekBarDisplay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val color = MaterialTheme.colorScheme.border val color = MaterialTheme.colorScheme.border
val onSurface = MaterialTheme.colorScheme.onSurface val onSurface = MaterialTheme.colorScheme.onSurface
@ -225,13 +222,7 @@ private fun SeekBarDisplay(
Timber.v("Ignoring %s", event) Timber.v("Ignoring %s", event)
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
} }
val seekBack = if (isDpadLeft(event)) {
if (isLtr) {
isDpadLeft(event)
} else {
isDpadRight(event)
}
if (seekBack) {
when (event.type) { when (event.type) {
KeyEventType.KeyDown -> { KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount val repeatCount = event.nativeKeyEvent.repeatCount
@ -300,68 +291,34 @@ private fun SeekBarDisplay(
strokeWidth = size.height, strokeWidth = size.height,
cap = StrokeCap.Round, cap = StrokeCap.Round,
) )
if (isLtr) { drawLine(
drawLine( color = onSurface.copy(alpha = .65f),
color = onSurface.copy(alpha = .65f), start = Offset(x = 0f, y = yOffset),
start = Offset(x = 0f, y = yOffset), end =
end = Offset(
Offset( x = size.width.times(bufferedProgress),
x = size.width.times(bufferedProgress), y = yOffset,
y = yOffset, ),
), strokeWidth = size.height,
strokeWidth = size.height, cap = StrokeCap.Round,
cap = StrokeCap.Round, )
) drawLine(
drawLine( color = color,
color = color, start = Offset(x = 0f, y = yOffset),
start = Offset(x = 0f, y = yOffset), end =
end = Offset(
Offset( x = size.width.times(progress),
x = size.width.times(progress), y = yOffset,
y = yOffset, ),
), strokeWidth = size.height,
strokeWidth = size.height, cap = StrokeCap.Round,
cap = StrokeCap.Round, )
)
} else {
drawLine(
color = onSurface.copy(alpha = .65f),
start =
Offset(
x = size.width - size.width.times(bufferedProgress),
y = yOffset,
),
end =
Offset(
x = size.width,
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = color,
start = Offset(x = size.width - size.width.times(progress), y = yOffset),
end =
Offset(
x = size.width,
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
}
drawCircle( drawCircle(
color = Color.White, color = Color.White,
radius = size.height + 2, radius = size.height + 2,
center = center =
Offset( Offset(
x = x = size.width.times(progress),
if (isLtr) {
size.width.times(progress)
} else {
size.width - size.width.times(progress)
},
y = yOffset, y = yOffset,
), ),
) )