Fix several issues with right-to-left locales (#1404)

## Description
Fix several issues when the current locale is right-to-left

- Fixes the backdrop dynamic colors
- Fixes mirroring of playback controls, including seeking

### Related issues
Fixes #1397
Fixes #1398

### Testing
Emulator w/ forced RTL in developer options

## Screenshots
<img width="1280" height="720" alt="rtl_backdrop Large"
src="https://github.com/user-attachments/assets/3e3b14ec-32cc-42c0-a224-d1d859a55acd"
/>
<img width="1280" height="720" alt="rtl_controls Large"
src="https://github.com/user-attachments/assets/79983bb9-e1d0-4712-a5c6-da3d0721db96"
/>


## AI or LLM usage

None
This commit is contained in:
Damontecres 2026-05-25 13:27:34 -04:00 committed by GitHub
parent 656393fa1d
commit 9eff22e939
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 214 additions and 117 deletions

View file

@ -45,7 +45,9 @@ 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,9 +108,11 @@ fun NowPlayingPage(
).value.appPreferences
val musicPrefs = preferences.musicPreferences
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val keyHandler =
remember(preferences) {
remember(isLtr, preferences) {
PlaybackKeyHandler(
isLtr = isLtr,
player = player,
controlsEnabled = true,
skipWithLeftRight = false,

View file

@ -23,6 +23,8 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.MaterialTheme
@ -75,6 +77,7 @@ fun Backdrop(
useExistingImageAsPlaceholder: Boolean = false,
crossfadeDuration: Duration = 800.milliseconds,
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val baseBackgroundColor = MaterialTheme.colorScheme.background
if (backdrop.hasColors &&
(backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED)
@ -100,12 +103,14 @@ fun Backdrop(
.fillMaxSize()
.drawBehind {
drawRect(color = baseBackgroundColor)
val start = if (isRtl) size.width else 0f
val end = if (isRtl) 0f else size.width
// Top Left (Vibrant/Muted)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(animSecondary, Color.Transparent),
center = Offset(0f, 0f),
center = Offset(start, 0f),
radius = size.width * 0.8f,
),
)
@ -114,7 +119,7 @@ fun Backdrop(
brush =
Brush.radialGradient(
colors = listOf(animPrimary, Color.Transparent),
center = Offset(size.width, size.height),
center = Offset(end, size.height),
radius = size.width * 0.8f,
),
)
@ -127,7 +132,7 @@ fun Backdrop(
baseBackgroundColor,
Color.Transparent,
),
center = Offset(0f, size.height),
center = Offset(start, size.height),
radius = size.width * 0.8f,
),
)
@ -136,7 +141,7 @@ fun Backdrop(
brush =
Brush.radialGradient(
colors = listOf(animTertiary, Color.Transparent),
center = Offset(size.width, 0f),
center = Offset(end, 0f),
radius = size.width * 0.8f,
),
)
@ -165,6 +170,7 @@ fun Backdrop(
.fillMaxWidth(.7f)
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
val start = if (isRtl) size.width else 0f
drawContent()
if (drawerIsOpen) {
drawRect(
@ -190,7 +196,7 @@ fun Backdrop(
brush =
Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = 0f,
startX = start,
endX = size.width * 0.6f,
),
blendMode = BlendMode.DstIn,

View file

@ -32,6 +32,14 @@ fun isControllerMedia(event: KeyEvent) =
event.key == Key.ButtonL1 ||
event.key == Key.ButtonL2
fun isDpadLeft(event: KeyEvent) =
event.key == Key.DirectionLeft ||
event.key == Key.SystemNavigationLeft
fun isDpadRight(event: KeyEvent) =
event.key == Key.DirectionRight ||
event.key == Key.SystemNavigationRight
fun isSkipBack(event: KeyEvent) =
event.key == Key.DirectionLeft ||
event.key == Key.ButtonL1 ||

View file

@ -21,8 +21,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@ -85,6 +87,10 @@ fun PlaybackDialog(
onPlaybackActionClick: (PlaybackAction) -> Unit,
onChangeSubtitleDelay: (Duration) -> Unit,
) {
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
// TODO, shouldn't this work out of the box?
val leftGravity = remember(isLtr) { if (isLtr) Gravity.START else Gravity.END }
val rightGravity = remember(isLtr) { if (isLtr) Gravity.END else Gravity.START }
when (type) {
PlaybackDialogType.DEBUG -> {
throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG)
@ -113,7 +119,7 @@ fun PlaybackDialog(
onDismissRequest.invoke()
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
},
gravity = Gravity.END,
gravity = rightGravity,
)
}
@ -167,7 +173,7 @@ fun PlaybackDialog(
onClickPlaybackDialogType(choice.data)
}
},
gravity = Gravity.START,
gravity = leftGravity,
)
}
@ -182,7 +188,7 @@ fun PlaybackDialog(
onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index))
},
gravity = Gravity.END,
gravity = rightGravity,
)
}
@ -205,7 +211,7 @@ fun PlaybackDialog(
onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
},
gravity = Gravity.START,
gravity = leftGravity,
)
}
@ -228,7 +234,7 @@ fun PlaybackDialog(
onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data))
},
gravity = Gravity.START,
gravity = leftGravity,
)
}

View file

@ -15,6 +15,7 @@ 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,
@ -47,11 +48,21 @@ class PlaybackKeyHandler(
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && isSkipBack(it)) {
if (isLtr) {
updateSkipIndicator(-seekBack.inWholeMilliseconds)
player.seekBack(seekBack)
} else {
updateSkipIndicator(seekForward.inWholeMilliseconds)
player.seekForward(seekForward)
}
} else if (skipWithLeftRight && isSkipForward(it)) {
if (isLtr) {
player.seekForward(seekForward)
updateSkipIndicator(seekForward.inWholeMilliseconds)
} else {
player.seekBack(seekBack)
updateSkipIndicator(seekBack.inWholeMilliseconds)
}
} else if (oneClickPause && isEnterKey(it)) {
val wasPlaying = player.isPlaying
Util.handlePlayPauseButtonAction(player)
@ -127,7 +138,7 @@ class PlaybackKeyHandler(
return false
}
val isBack = isSkipBack(event)
val isBack = if (isLtr) isSkipBack(event) else isSkipForward(event)
return when (event.type) {
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount

View file

@ -50,7 +50,9 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
@ -234,8 +236,11 @@ fun PlaybackPageContent(
skipIndicatorDuration += delta
skipPosition = player.currentPosition
}
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val keyHandler =
remember(isLtr, preferences) {
PlaybackKeyHandler(
isLtr = isLtr,
player = player,
controlsEnabled = state.nextUp == null,
skipWithLeftRight = true,
@ -253,6 +258,7 @@ fun PlaybackPageContent(
},
onPlaybackDialogTypeClick = { playbackDialog = it },
)
}
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
when (it) {

View file

@ -22,7 +22,9 @@ import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
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.media3.common.Player
import androidx.tv.material3.MaterialTheme
@ -47,6 +49,7 @@ fun ChapterRowOverlay(
onChangeState: (OverlayViewState) -> Unit,
modifier: Modifier = Modifier,
) {
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -135,7 +138,10 @@ fun ChapterRowOverlay(
index == 0,
Modifier.focusProperties {
// Prevent scrolling left on first card to prevent moving down
left = FocusRequester.Cancel
left =
if (isLtr) FocusRequester.Cancel else FocusRequester.Default
right =
if (isLtr) FocusRequester.Default else FocusRequester.Cancel
},
),
)

View file

@ -42,10 +42,12 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@ -368,12 +370,13 @@ fun PlaybackButtons(
seekForward: Duration,
modifier: Modifier = Modifier,
) {
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) {
PlaybackButton(
iconRes = R.drawable.baseline_skip_previous_24,
iconRes = if (isLtr) R.drawable.baseline_skip_previous_24 else R.drawable.baseline_skip_next_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Previous)
@ -382,7 +385,7 @@ fun PlaybackButtons(
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_fast_rewind_24,
iconRes = if (isLtr) R.drawable.baseline_fast_rewind_24 else R.drawable.baseline_fast_forward_24,
onClick = {
onControllerInteraction.invoke()
player.seekBack(seekBack)
@ -406,7 +409,7 @@ fun PlaybackButtons(
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_fast_forward_24,
iconRes = if (isLtr) R.drawable.baseline_fast_forward_24 else R.drawable.baseline_fast_rewind_24,
onClick = {
onControllerInteraction.invoke()
player.seekForward(seekForward)
@ -414,7 +417,7 @@ fun PlaybackButtons(
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_skip_next_24,
iconRes = if (isLtr) R.drawable.baseline_skip_next_24 else R.drawable.baseline_skip_previous_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Next)

View file

@ -18,7 +18,6 @@ package com.github.damontecres.wholphin.ui.playback.overlay
* limitations under the License.
*/
import android.view.KeyEvent
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable
@ -44,11 +43,16 @@ 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
import com.github.damontecres.wholphin.ui.playback.calculateSeekAccelerationMultiplier
import com.github.damontecres.wholphin.ui.playback.isDpadLeft
import com.github.damontecres.wholphin.ui.playback.isDpadRight
import kotlinx.coroutines.FlowPreview
import timber.log.Timber
import kotlin.time.Duration
/**
@ -174,6 +178,7 @@ 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
@ -192,8 +197,17 @@ private fun SeekBarDisplay(
.height(animatedIndicatorHeight)
.padding(horizontal = 4.dp)
.onPreviewKeyEvent { event ->
when (event.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
if (!isDpadLeft(event) && !isDpadRight(event)) {
Timber.v("Ignoring %s", event)
return@onPreviewKeyEvent false
}
val seekBack =
if (isLtr) {
isDpadLeft(event)
} else {
isDpadRight(event)
}
if (seekBack) {
when (event.type) {
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
@ -222,9 +236,7 @@ private fun SeekBarDisplay(
}
}
return@onPreviewKeyEvent true
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
} else {
when (event.type) {
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
@ -254,8 +266,6 @@ private fun SeekBarDisplay(
}
return@onPreviewKeyEvent true
}
}
false
}.focusable(enabled = enabled, interactionSource = interactionSource),
onDraw = {
val yOffset = size.height.div(2)
@ -266,6 +276,7 @@ private fun SeekBarDisplay(
strokeWidth = size.height,
cap = StrokeCap.Round,
)
if (isLtr) {
drawLine(
color = onSurface.copy(alpha = .65f),
start = Offset(x = 0f, y = yOffset),
@ -282,17 +293,53 @@ private fun SeekBarDisplay(
start = Offset(x = 0f, y = yOffset),
end =
Offset(
// x = size.width.times(if (isSelected) seekProgress else progress),
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,
)
}
drawCircle(
color = Color.White,
radius = size.height + 2,
center = Offset(x = size.width.times(progress), y = yOffset),
center =
Offset(
x =
if (isLtr) {
size.width.times(progress)
} else {
size.width - size.width.times(progress)
},
y = yOffset,
),
)
},
)