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

View file

@ -16,7 +16,6 @@ import kotlin.time.Duration
* Handles [KeyEvent]s during playback on [PlaybackPage]
*/
class PlaybackKeyHandler(
private val isLtr: Boolean,
private val player: Player,
private val controlsEnabled: Boolean,
private val skipWithLeftRight: Boolean,
@ -53,17 +52,9 @@ class PlaybackKeyHandler(
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && isSkipBack(it)) {
if (isLtr) {
seekBy(-seekBack)
} else {
seekBy(seekForward)
}
seekBy(-seekBack)
} else if (skipWithLeftRight && isSkipForward(it)) {
if (isLtr) {
seekBy(seekForward)
} else {
seekBy(-seekBack)
}
seekBy(seekForward)
} else if (isEnterKey(it) && isDpadSeekVisible()) {
// If d-pad seek bar is visible, hide it
clearSkipIndicator.invoke()
@ -142,7 +133,7 @@ class PlaybackKeyHandler(
return false
}
val isBack = if (isLtr) isSkipBack(event) else isSkipForward(event)
val isBack = isSkipBack(event)
return when (event.type) {
KeyEventType.KeyDown -> {
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.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -243,11 +244,9 @@ fun PlaybackPageContent(
)
seekBarState.onValueChange(skipPosition)
}
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val keyHandler =
remember(isLtr, preferences) {
remember(preferences) {
PlaybackKeyHandler(
isLtr = isLtr,
player = player,
controlsEnabled = state.nextUp == null,
skipWithLeftRight = true,
@ -390,17 +389,19 @@ fun PlaybackPageContent(
Modifier
.align(Alignment.BottomCenter),
) {
DpadSeekOverlay(
player = player,
seekPositionMs = skipPosition,
trickplayInfo = state.currentMediaInfo.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.fillMaxWidth(.95f),
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
DpadSeekOverlay(
player = player,
seekPositionMs = skipPosition,
trickplayInfo = state.currentMediaInfo.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.fillMaxWidth(.95f),
)
}
// Clear the overlay after a delay
LaunchedEffect(skipIndicatorDuration) {
delay(1.5.seconds)
@ -426,15 +427,23 @@ fun PlaybackPageContent(
// Show a small progress bar along the bottom of the screen
if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) {
val percent = skipPosition.toFloat() / player.duration.toFloat()
Box(
modifier =
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Box(
Modifier
.align(Alignment.BottomStart)
.background(MaterialTheme.colorScheme.border)
.clip(RectangleShape)
.height(3.dp)
.fillMaxWidth(percent),
)
.fillMaxWidth()
.align(Alignment.BottomCenter),
) {
Box(
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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@ -191,6 +192,7 @@ fun PlaybackControls(
onClickPlaybackDialogType = onClickPlaybackDialogType,
modifier = Modifier.align(Alignment.CenterStart),
)
PlaybackButtons(
player = player,
initialFocusRequester = initialFocusRequester,
@ -204,6 +206,7 @@ fun PlaybackControls(
skipBackOnResume = skipBackOnResume,
modifier = Modifier.align(Alignment.Center),
)
Row(
modifier = Modifier.align(Alignment.CenterEnd),
) {
@ -286,31 +289,40 @@ fun SeekTimecodes(
durationMs: Long,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
val resources = LocalResources.current
val positionSec = positionMs / 1000
val remainingSec = (durationMs - positionMs) / 1000
val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) }
val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" }
Text(
text = positionText,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(8.dp),
)
Text(
text = remainingText,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(8.dp),
)
val currentLayoutDirection = LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
val resources = LocalResources.current
val positionSec = positionMs / 1000
val remainingSec = (durationMs - positionMs) / 1000
val positionText =
remember(positionSec) { resources.formatDuration(positionSec.seconds) }
val remainingText =
remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" }
CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) {
Text(
text = positionText,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
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,
modifier: Modifier = Modifier,
) {
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) {
PlaybackButton(
iconRes = if (isLtr) R.drawable.baseline_skip_previous_24 else R.drawable.baseline_skip_next_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Previous)
},
enabled = previousEnabled,
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = if (isLtr) R.drawable.baseline_fast_rewind_24 else R.drawable.baseline_fast_forward_24,
onClick = {
onControllerInteraction.invoke()
player.seekBack(seekBack)
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
modifier = Modifier.focusRequester(initialFocusRequester),
iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
onClick = {
onControllerInteraction.invoke()
if (showPlay) {
player.play()
skipBackOnResume?.let {
player.seekBack(it)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) {
PlaybackButton(
iconRes = R.drawable.baseline_skip_previous_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Previous)
},
enabled = previousEnabled,
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_fast_rewind_24,
onClick = {
onControllerInteraction.invoke()
player.seekBack(seekBack)
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
modifier = Modifier.focusRequester(initialFocusRequester),
iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
onClick = {
onControllerInteraction.invoke()
if (showPlay) {
player.play()
skipBackOnResume?.let {
player.seekBack(it)
}
} else {
player.pause()
}
} else {
player.pause()
}
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = if (isLtr) R.drawable.baseline_fast_forward_24 else R.drawable.baseline_fast_rewind_24,
onClick = {
onControllerInteraction.invoke()
player.seekForward(seekForward)
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = if (isLtr) R.drawable.baseline_skip_next_24 else R.drawable.baseline_skip_previous_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Next)
},
enabled = nextEnabled,
onControllerInteraction = onControllerInteraction,
)
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_fast_forward_24,
onClick = {
onControllerInteraction.invoke()
player.seekForward(seekForward)
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_skip_next_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Next)
},
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.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import coil3.compose.AsyncImage
@ -254,39 +257,41 @@ fun PlaybackOverlay(
}
}
// Trickplay
AnimatedVisibility(
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.fillMaxWidth(.95f),
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
// Trickplay
AnimatedVisibility(
visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
.align(Alignment.Center)
.fillMaxWidth(.95f),
) {
TrickplayPreview(
seekProgressMs = seekProgressMs,
trickplayInfo = trickplayInfo,
trickplayUrlFor = trickplayUrlFor,
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
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.onPreviewKeyEvent
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.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
@ -202,7 +200,6 @@ private fun SeekBarDisplay(
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val color = MaterialTheme.colorScheme.border
val onSurface = MaterialTheme.colorScheme.onSurface
@ -225,13 +222,7 @@ private fun SeekBarDisplay(
Timber.v("Ignoring %s", event)
return@onPreviewKeyEvent false
}
val seekBack =
if (isLtr) {
isDpadLeft(event)
} else {
isDpadRight(event)
}
if (seekBack) {
if (isDpadLeft(event)) {
when (event.type) {
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
@ -300,68 +291,34 @@ private fun SeekBarDisplay(
strokeWidth = size.height,
cap = StrokeCap.Round,
)
if (isLtr) {
drawLine(
color = onSurface.copy(alpha = .65f),
start = Offset(x = 0f, y = yOffset),
end =
Offset(
x = size.width.times(bufferedProgress),
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = color,
start = Offset(x = 0f, y = yOffset),
end =
Offset(
x = size.width.times(progress),
y = yOffset,
),
strokeWidth = size.height,
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,
)
}
drawLine(
color = onSurface.copy(alpha = .65f),
start = Offset(x = 0f, y = yOffset),
end =
Offset(
x = size.width.times(bufferedProgress),
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = color,
start = Offset(x = 0f, y = yOffset),
end =
Offset(
x = size.width.times(progress),
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawCircle(
color = Color.White,
radius = size.height + 2,
center =
Offset(
x =
if (isLtr) {
size.width.times(progress)
} else {
size.width - size.width.times(progress)
},
x = size.width.times(progress),
y = yOffset,
),
)