chore: Android TV UI clean-up

This commit is contained in:
PartyDonut 2025-10-05 10:24:00 +02:00
parent fedc00c65b
commit 721fc28060
9 changed files with 119 additions and 74 deletions

View file

@ -1,16 +1,16 @@
package nl.jknaapen.fladder.composables.controls package nl.jknaapen.fladder.composables.controls
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -23,42 +23,37 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import nl.jknaapen.fladder.utility.conditional import nl.jknaapen.fladder.utility.conditional
import nl.jknaapen.fladder.utility.highlightOnFocus
@Composable @Composable
internal fun CustomIconButton( internal fun CustomIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
enableFocusIndicator: Boolean = true,
enableScaledFocus: Boolean = false, enableScaledFocus: Boolean = false,
backgroundColor: Color = Color.Transparent, backgroundColor: Color = Color.White.copy(alpha = 0.1f),
foreGroundColor: Color = Color.White, foreGroundColor: Color = Color.White,
backgroundFocusedColor: Color = Color.Transparent, backgroundFocusedColor: Color = Color.White,
foreGroundFocusedColor: Color = Color.White, foreGroundFocusedColor: Color = Color.Black,
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val currentContentColor by remember {
derivedStateOf {
if (isFocused) {
foreGroundFocusedColor
} else {
foreGroundColor
}
}
}
val currentBackgroundColor by remember { val currentContentColor by animateColorAsState(
derivedStateOf { if (isFocused) {
if (isFocused) { foreGroundFocusedColor
backgroundFocusedColor } else {
} else { foreGroundColor
backgroundColor }, label = "buttonContentColor"
} )
}
} val currentBackgroundColor by animateColorAsState(
if (isFocused) {
backgroundFocusedColor
} else {
backgroundColor
}, label = "buttonBackground"
)
Box( Box(
modifier = modifier modifier = modifier
@ -66,10 +61,7 @@ internal fun CustomIconButton(
.conditional(enableScaledFocus) { .conditional(enableScaledFocus) {
scale(if (isFocused) 1.05f else 1f) scale(if (isFocused) 1.05f else 1f)
} }
.conditional(enableFocusIndicator) { .background(currentBackgroundColor, shape = CircleShape)
highlightOnFocus()
}
.background(currentBackgroundColor, shape = RoundedCornerShape(8.dp))
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.clickable( .clickable(
enabled = enabled, enabled = enabled,
@ -81,7 +73,7 @@ internal fun CustomIconButton(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CompositionLocalProvider(LocalContentColor provides currentContentColor) { CompositionLocalProvider(LocalContentColor provides currentContentColor) {
Box(modifier = Modifier.padding(8.dp)) { Box(modifier = Modifier.padding(16.dp)) {
icon() icon()
} }
} }

View file

@ -4,6 +4,8 @@ import MediaSegment
import MediaSegmentType import MediaSegmentType
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
@ -57,6 +59,7 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastCoerceIn
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@ -95,7 +98,9 @@ internal fun ProgressBar(
} }
} }
Column { Column(
verticalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.CenterVertically)
) {
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null) val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
if (scrubbingTimeLine) if (scrubbingTimeLine)
FilmstripTrickPlayOverlay( FilmstripTrickPlayOverlay(
@ -108,20 +113,23 @@ internal fun ProgressBar(
trickPlayModel = playbackData?.trickPlayModel trickPlayModel = playbackData?.trickPlayModel
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
val subTitle = playableData?.subTitle val subTitle = playableData?.subTitle
subTitle?.let { subTitle?.let {
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White), style = MaterialTheme.typography.titleLarge.copy(
color = Color.White,
fontWeight = FontWeight.Bold
),
) )
} }
VideoEndTime() VideoEndTime()
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy( horizontalArrangement = Arrangement.spacedBy(
8.dp, 16.dp,
alignment = Alignment.CenterHorizontally alignment = Alignment.CenterHorizontally
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -130,7 +138,9 @@ internal fun ProgressBar(
Text( Text(
formatTime(currentPosition), formatTime(currentPosition),
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold
)
) )
SimpleProgressBar( SimpleProgressBar(
player, player,
@ -153,7 +163,9 @@ internal fun ProgressBar(
) )
), ),
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold
)
) )
} }
} }
@ -200,7 +212,7 @@ internal fun RowScope.SimpleProgressBar(
width = it.size.width width = it.size.width
} }
) )
.heightIn(min = 26.dp) .heightIn(min = 42.dp)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { offset -> detectTapGestures { offset ->
onUserInteraction() onUserInteraction()
@ -241,14 +253,19 @@ internal fun RowScope.SimpleProgressBar(
modifier = Modifier modifier = Modifier
.focusable(enabled = false) .focusable(enabled = false)
.fillMaxWidth() .fillMaxWidth()
.height(8.dp) .height(10.dp)
.background( .background(
color = Color.Black.copy( color = Color.White.copy(
alpha = 0.15f alpha = 0.15f
), ),
shape = slideBarShape shape = slideBarShape
), ),
) { ) {
val animatedBarColor by animateColorAsState(
if (thumbFocused) MaterialTheme.colorScheme.primary else Color.White,
label = "progressBarColor"
)
Box( Box(
modifier = Modifier modifier = Modifier
.focusable(enabled = false) .focusable(enabled = false)
@ -256,9 +273,7 @@ internal fun RowScope.SimpleProgressBar(
.fillMaxWidth(progress) .fillMaxWidth(progress)
.padding(end = 8.dp) .padding(end = 8.dp)
.background( .background(
color = if (thumbFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy( color = animatedBarColor,
alpha = 0.75f
),
shape = slideBarShape shape = slideBarShape
) )
) )
@ -292,10 +307,10 @@ internal fun RowScope.SimpleProgressBar(
.focusable(enabled = false) .focusable(enabled = false)
.graphicsLayer { .graphicsLayer {
translationX = startPx translationX = startPx
translationY = 16.dp.toPx() translationY = 20.dp.toPx()
} }
.width(segDp) .width(segDp)
.height(6.dp) .height(8.dp)
.background( .background(
color = segment.color.copy(alpha = 0.75f), color = segment.color.copy(alpha = 0.75f),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
@ -318,6 +333,7 @@ internal fun RowScope.SimpleProgressBar(
val durMs = duration.toDouble().coerceAtLeast(1.0) val durMs = duration.toDouble().coerceAtLeast(1.0)
val startPx = (width * (segStartMs / durMs)).toFloat() val startPx = (width * (segStartMs / durMs)).toFloat()
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
@ -331,7 +347,7 @@ internal fun RowScope.SimpleProgressBar(
.aspectRatio(ratio = 1f) .aspectRatio(ratio = 1f)
.background( .background(
color = if (isAfterCurrentPositon) Color.White.copy( color = if (isAfterCurrentPositon) Color.White.copy(
alpha = 0.5f alpha = 0.15f
) else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), ) else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
shape = CircleShape shape = CircleShape
) )
@ -339,6 +355,12 @@ internal fun RowScope.SimpleProgressBar(
} }
} }
val animatedThumbHeight by animateDpAsState(
if (thumbFocused) 28.dp else 14.dp,
label = "Thumb height"
)
//Thumb //Thumb
Box( Box(
modifier = Modifier modifier = Modifier
@ -414,8 +436,8 @@ internal fun RowScope.SimpleProgressBar(
color = Color.White, color = Color.White,
shape = CircleShape, shape = CircleShape,
) )
.width(8.dp) .width(14.dp)
.height(if (thumbFocused) 21.dp else 8.dp) .height(animatedThumbHeight)
) )
} }
} }

View file

@ -51,7 +51,6 @@ import kotlin.time.Duration.Companion.milliseconds
internal fun BoxScope.SegmentSkipOverlay( internal fun BoxScope.SegmentSkipOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isAndroidTV = leanBackEnabled(LocalContext.current) val isAndroidTV = leanBackEnabled(LocalContext.current)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.objects.VideoPlayerObject
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -39,6 +40,9 @@ fun VideoEndTime() {
Text( Text(
text = "ends at $formattedEnd", text = "ends at $formattedEnd",
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White), style = MaterialTheme.typography.titleLarge.copy(
color = Color.White,
fontWeight = FontWeight.Bold
),
) )
} }

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
@ -96,6 +97,7 @@ fun CustomVideoControls(
val buffering by VideoPlayerObject.buffering.collectAsState(true) val buffering by VideoPlayerObject.buffering.collectAsState(true)
val playing by VideoPlayerObject.playing.collectAsState(false) val playing by VideoPlayerObject.playing.collectAsState(false)
val controlsPadding = 32.dp
ImmersiveSystemBars(isImmersive = !showControls) ImmersiveSystemBars(isImmersive = !showControls)
@ -131,9 +133,12 @@ fun CustomVideoControls(
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
if (!showControls) { if (!showControls) {
bottomControlFocusRequester.requestFocus() bottomControlFocusRequester.requestFocus()
updateLastInteraction()
return@onKeyEvent true
} else {
updateLastInteraction()
return@onKeyEvent false
} }
updateLastInteraction()
return@onKeyEvent false
} }
.clickable( .clickable(
indication = null, indication = null,
@ -170,7 +175,7 @@ fun CustomVideoControls(
), ),
) )
.safeContentPadding(), .safeContentPadding(),
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start) horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Start)
) { ) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -203,7 +208,7 @@ fun CustomVideoControls(
// Progress Bar // Progress Bar
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = controlsPadding)
.displayCutoutPadding(), .displayCutoutPadding(),
) { ) {
ProgressBar( ProgressBar(
@ -227,8 +232,8 @@ fun CustomVideoControls(
), ),
) )
.displayCutoutPadding() .displayCutoutPadding()
.padding(horizontal = 16.dp) .padding(horizontal = controlsPadding)
.padding(top = 8.dp, bottom = 16.dp) .padding(top = 8.dp, bottom = controlsPadding)
) { ) {
LeftButtons( LeftButtons(
openChapterSelection = { openChapterSelection = {
@ -245,6 +250,9 @@ fun CustomVideoControls(
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier
.align(alignment = Alignment.Center) .align(alignment = Alignment.Center)
.size(64.dp),
strokeCap = StrokeCap.Round,
strokeWidth = 12.dp
) )
} }
} }
@ -302,7 +310,7 @@ fun PlaybackButtons(
.padding(horizontal = 4.dp, vertical = 6.dp) .padding(horizontal = 4.dp, vertical = 6.dp)
.wrapContentWidth(), .wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy( horizontalArrangement = Arrangement.spacedBy(
8.dp, 16.dp,
alignment = Alignment.CenterHorizontally alignment = Alignment.CenterHorizontally
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -315,7 +323,6 @@ fun PlaybackButtons(
Iconsax.Filled.Backward, Iconsax.Filled.Backward,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
contentDescription = previousVideo, contentDescription = previousVideo,
tint = Color.White
) )
} }
CustomIconButton( CustomIconButton(

View file

@ -44,6 +44,8 @@ internal fun ChapterSelectionSheet(
val chapters = playbackData?.chapters ?: listOf() val chapters = playbackData?.chapters ?: listOf()
val currentPosition by VideoPlayerObject.position.collectAsState(0L) val currentPosition by VideoPlayerObject.position.collectAsState(0L)
if (chapters.isEmpty()) return
var currentChapter: Chapter? by remember { var currentChapter: Chapter? by remember {
mutableStateOf( mutableStateOf(
chapters[chapters.indexOfCurrent( chapters[chapters.indexOfCurrent(
@ -54,7 +56,7 @@ internal fun ChapterSelectionSheet(
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
LaunchedEffect(Unit) { LaunchedEffect(chapters) {
lazyListState.animateScrollToItem( lazyListState.animateScrollToItem(
chapters.indexOfCurrent(currentPosition) chapters.indexOfCurrent(currentPosition)
) )

View file

@ -21,6 +21,7 @@ import androidx.core.content.getSystemService
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
@ -76,6 +77,7 @@ internal fun ExoPlayer(
val audioAttributes = AudioAttributes.Builder() val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build() .build()
val renderersFactory = DefaultRenderersFactory(context) val renderersFactory = DefaultRenderersFactory(context)
@ -84,6 +86,11 @@ internal fun ExoPlayer(
val trackSelector = DefaultTrackSelector(context).apply { val trackSelector = DefaultTrackSelector(context).apply {
setParameters(buildUponParameters().apply { setParameters(buildUponParameters().apply {
setAudioOffloadPreferences(
TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply {
setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
}.build()
)
setTunnelingEnabled(PlayerSettingsObject.settings.value?.enableTunneling ?: false) setTunnelingEnabled(PlayerSettingsObject.settings.value?.enableTunneling ?: false)
setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true) setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
}) })
@ -100,6 +107,7 @@ internal fun ExoPlayer(
.buildWithAssSupport( .buildWithAssSupport(
context, context,
renderersFactory = renderersFactory, renderersFactory = renderersFactory,
extractorsFactory = extractorsFactory,
renderType = AssRenderType.LEGACY renderType = AssRenderType.LEGACY
) )
} }

View file

@ -34,17 +34,26 @@ fun Modifier.highlightOnFocus(
): Modifier = composed { ): Modifier = composed {
var hasFocus by remember { mutableStateOf(false) } var hasFocus by remember { mutableStateOf(false) }
val highlightModifier = remember { val highlightModifier = remember {
Modifier if (width != 0.dp) {
.clip(RoundedCornerShape(8.dp)) Modifier
.background( .clip(RoundedCornerShape(8.dp))
color = color.copy(alpha = 0.25f), .background(
shape = shape, color = color.copy(alpha = 0.25f),
) shape = shape,
.border( )
width = width, .border(
color = color.copy(alpha = 0.5f), width = width,
shape = shape color = color.copy(alpha = 0.5f),
) shape = shape
)
} else {
Modifier
.clip(RoundedCornerShape(8.dp))
.background(
color = color.copy(alpha = 0.25f),
shape = shape,
)
}
} }
this this

View file

@ -219,15 +219,17 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
onFocusChange: (value) { onFocusChange: (value) {
if (value) { if (value) {
final nodesOnSameRow = _nodesInRow(parentNode); final nodesOnSameRow = _nodesInRow(parentNode);
final focusNode = lastFocused ?? _firstFullyVisibleNode(context, nodesOnSameRow); final currentNode =
nodesOnSameRow.contains(lastFocused) ? lastFocused : _firstFullyVisibleNode(context, nodesOnSameRow);
if (focusNode != null) { if (currentNode != null) {
lastFocused = currentNode;
if (widget.onFocused != null) { if (widget.onFocused != null) {
widget.onFocused!(nodesOnSameRow.indexOf(focusNode)); widget.onFocused!(nodesOnSameRow.indexOf(currentNode));
} else { } else {
context.ensureVisible(); context.ensureVisible();
} }
focusNode.requestFocus(); currentNode.requestFocus();
} }
} }
}, },
@ -240,7 +242,7 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
child: FocusTraversalGroup( child: FocusTraversalGroup(
policy: HorizontalRailFocus( policy: HorizontalRailFocus(
parentNode: parentNode, parentNode: parentNode,
throttle: Throttler(duration: const Duration(milliseconds: 125)), throttle: Throttler(duration: const Duration(milliseconds: 100)),
onFocused: (node) { onFocused: (node) {
lastFocused = node; lastFocused = node;
final nodesOnSameRow = _nodesInRow(parentNode); final nodesOnSameRow = _nodesInRow(parentNode);