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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import nl.jknaapen.fladder.objects.VideoPlayerObject
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@ -39,6 +40,9 @@ fun VideoEndTime() {
Text(
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.graphics.Brush
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.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
@ -96,6 +97,7 @@ fun CustomVideoControls(
val buffering by VideoPlayerObject.buffering.collectAsState(true)
val playing by VideoPlayerObject.playing.collectAsState(false)
val controlsPadding = 32.dp
ImmersiveSystemBars(isImmersive = !showControls)
@ -131,10 +133,13 @@ fun CustomVideoControls(
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
if (!showControls) {
bottomControlFocusRequester.requestFocus()
}
updateLastInteraction()
return@onKeyEvent true
} else {
updateLastInteraction()
return@onKeyEvent false
}
}
.clickable(
indication = null,
interactionSource = interactionSource,
@ -170,7 +175,7 @@ fun CustomVideoControls(
),
)
.safeContentPadding(),
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start)
horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Start)
) {
Column(
modifier = Modifier.weight(1f),
@ -203,7 +208,7 @@ fun CustomVideoControls(
// Progress Bar
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(horizontal = controlsPadding)
.displayCutoutPadding(),
) {
ProgressBar(
@ -227,8 +232,8 @@ fun CustomVideoControls(
),
)
.displayCutoutPadding()
.padding(horizontal = 16.dp)
.padding(top = 8.dp, bottom = 16.dp)
.padding(horizontal = controlsPadding)
.padding(top = 8.dp, bottom = controlsPadding)
) {
LeftButtons(
openChapterSelection = {
@ -245,6 +250,9 @@ fun CustomVideoControls(
CircularProgressIndicator(
modifier = Modifier
.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)
.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(
8.dp,
16.dp,
alignment = Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
@ -315,7 +323,6 @@ fun PlaybackButtons(
Iconsax.Filled.Backward,
modifier = Modifier.size(32.dp),
contentDescription = previousVideo,
tint = Color.White
)
}
CustomIconButton(

View file

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

View file

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

View file

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

View file

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