fix(live): left lands on the selected group, right enters a fresh group at the top

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 00:50:36 +02:00
parent a4391740ec
commit deca5bd0cb
2 changed files with 46 additions and 7 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 13 versionCode = 14
versionName = "0.7.0" versionName = "0.7.1"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -14,15 +14,21 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -76,6 +82,18 @@ fun LiveScreen(state: AppState) {
return return
} }
// D-pad choreography: left from any channel row lands on the SELECTED
// group (not the spatially nearest one), right from the rail enters the
// list at the top — which also gets scrolled up on every group change.
val railFocus = remember { FocusRequester() }
val listFocus = remember { FocusRequester() }
val listState = rememberLazyListState()
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
listState.scrollToItem(0)
}
val intoList = if (channels.isEmpty()) Modifier
else Modifier.focusProperties { right = listFocus }
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -87,10 +105,14 @@ fun LiveScreen(state: AppState) {
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
item { item {
val selected = !state.favoritesOnly && state.groupFilter == null
GroupItem( GroupItem(
label = "Alle Sender", label = "Alle Sender",
count = allChannels.size, count = allChannels.size,
selected = !state.favoritesOnly && state.groupFilter == null, selected = selected,
modifier = intoList.then(
if (selected) Modifier.focusRequester(railFocus) else Modifier
),
) { ) {
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = null state.groupFilter = null
@@ -102,6 +124,9 @@ fun LiveScreen(state: AppState) {
label = "★ Favoriten", label = "★ Favoriten",
count = favorites.size, count = favorites.size,
selected = state.favoritesOnly, selected = state.favoritesOnly,
modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
),
) { ) {
state.favoritesOnly = true state.favoritesOnly = true
state.groupFilter = null state.groupFilter = null
@@ -122,6 +147,9 @@ fun LiveScreen(state: AppState) {
label = group, label = group,
count = remember(allChannels, group) { allChannels.count { it.group == group } }, count = remember(allChannels, group) { allChannels.count { it.group == group } },
selected = state.groupFilter == group, selected = state.groupFilter == group,
modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
),
) { ) {
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = group state.groupFilter = group
@@ -130,6 +158,7 @@ fun LiveScreen(state: AppState) {
} }
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight(), .fillMaxHeight(),
@@ -151,12 +180,15 @@ fun LiveScreen(state: AppState) {
) )
} }
} }
items(channels, key = { it.url + it.name }) { channel -> itemsIndexed(channels, key = { _, c -> c.url + c.name }) { listIndex, channel ->
ChannelRow( ChannelRow(
channel = channel, channel = channel,
// Stable channel number = position in "Alle Sender", // Stable channel number = position in "Alle Sender",
// matching the remote's number pad in every view. // matching the remote's number pad in every view.
number = allChannels.indexOf(channel) + 1, number = allChannels.indexOf(channel) + 1,
modifier = Modifier
.focusProperties { left = railFocus }
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier),
showGroup = state.groupFilter == null, showGroup = state.groupFilter == null,
nowNext = state.nowNext(channel), nowNext = state.nowNext(channel),
playing = state.currentChannel?.url == channel.url, playing = state.currentChannel?.url == channel.url,
@@ -175,12 +207,18 @@ fun LiveScreen(state: AppState) {
} }
@Composable @Composable
private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: () -> Unit) { private fun GroupItem(
label: String,
count: Int,
selected: Boolean,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface( Surface(
// Focusing a group opens it right away (browse like a menu); // Focusing a group opens it right away (browse like a menu);
// clicking is not required, but harmless. // clicking is not required, but harmless.
onClick = onSelect, onClick = onSelect,
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { if (it.isFocused) onSelect() }, .onFocusChanged { if (it.isFocused) onSelect() },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
@@ -218,6 +256,7 @@ private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: ()
private fun ChannelRow( private fun ChannelRow(
channel: Channel, channel: Channel,
number: Int, number: Int,
modifier: Modifier = Modifier,
showGroup: Boolean, showGroup: Boolean,
nowNext: NowNext, nowNext: NowNext,
playing: Boolean, playing: Boolean,
@@ -229,7 +268,7 @@ private fun ChannelRow(
Surface( Surface(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent, containerColor = if (playing) CastarrColors.accentDim else Color.Transparent,