From 0b995b5404aba166a7931ade67ab1bc256f8418b Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 8 Sep 2026 22:51:54 -0400 Subject: [PATCH] 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 --- .../ui/detail/music/NowPlayingPage.kt | 6 +- .../ui/playback/PlaybackKeyHandler.kt | 15 +- .../wholphin/ui/playback/PlaybackPage.kt | 53 +++--- .../ui/playback/overlay/PlaybackControls.kt | 171 ++++++++++-------- .../ui/playback/overlay/PlaybackOverlay.kt | 63 ++++--- .../wholphin/ui/playback/overlay/SeekBar.kt | 91 +++------- 6 files changed, 185 insertions(+), 214 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 1c4cb71f..e37c7ac1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 71ea05e2..29bd30b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 209e8473..6ed210a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -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), + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index d8d43300..ba77d8b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -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, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index a1a83df6..d00fefea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -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, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index c03be433..d2aae720 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -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, ), )