From b04240ffde8ca285f16e137b01cae9d4282643ec Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 00:07:23 +0200 Subject: [PATCH] Live screen: group rail layout, exclusive filtering, cleaner top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Groups live in a scrollable rail on the left (Alle Sender, Favoriten, then Dispatcharr groups with counts); focusing one opens it like a menu — filters no longer stack additively. - Channel list sits right of the rail with Now/Next and progress; group subline only shown in the Alle view. - Top bar: wordmark plus one context pill (Einstellungen / Zurück zu Live) instead of two text tabs; Back returns to Live instead of quitting the app. Co-Authored-By: Claude Fable 5 --- .../main/java/dev/castarr/tv/ui/CastarrApp.kt | 24 ++- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 181 ++++++++++++------ 2 files changed, 142 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt index abe582f..80dca23 100644 --- a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt +++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt @@ -27,6 +27,14 @@ import dev.castarr.tv.AppState @Composable fun CastarrApp(state: AppState) { + // Back always leads home instead of quitting the app by accident. + androidx.activity.compose.BackHandler( + enabled = !state.playerVisible && state.screen != AppState.Screen.LIVE + && state.screen != AppState.Screen.WELCOME + ) { + state.screen = if (state.screen == AppState.Screen.ADVANCED) AppState.Screen.SETTINGS + else AppState.Screen.LIVE + } Box( modifier = Modifier .fillMaxSize() @@ -65,14 +73,6 @@ private fun TopBar(state: AppState) { fontWeight = FontWeight.Medium, letterSpacing = 4.sp, ) - Spacer(Modifier.width(36.dp)) - TabItem("Live", state.screen == AppState.Screen.LIVE) { - state.screen = AppState.Screen.LIVE - } - Spacer(Modifier.width(10.dp)) - TabItem("Einstellungen", state.screen == AppState.Screen.SETTINGS) { - state.screen = AppState.Screen.SETTINGS - } Spacer(Modifier.weight(1f)) state.connectedRemote?.let { name -> Row(verticalAlignment = Alignment.CenterVertically) { @@ -85,6 +85,14 @@ private fun TopBar(state: AppState) { Spacer(Modifier.width(8.dp)) Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp) } + Spacer(Modifier.width(18.dp)) + } + TabItem( + if (state.screen == AppState.Screen.LIVE) "⚙ Einstellungen" else "‹ Zurück zu Live", + selected = false, + ) { + state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS + else AppState.Screen.LIVE } } } diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index 3c70e88..b2d2d27 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -23,6 +23,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -36,6 +37,10 @@ import dev.castarr.tv.AppState import dev.castarr.tv.data.NowNext import dev.castarr.tv.playlist.Channel +/** + * Live tab: group rail on the left (focusing a group opens it — exclusive, + * like a menu), channels of the open group with Now/Next on the right. + */ @Composable fun LiveScreen(state: AppState) { val genericChannels by state.source.channels.collectAsStateWithLifecycle() @@ -50,9 +55,11 @@ fun LiveScreen(state: AppState) { val groups = remember(allChannels) { allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted() } - val channels = allChannels - .let { list -> if (isDispatcharr && state.favoritesOnly) list.filter { it.backendId in favorites } else list } - .let { list -> state.groupFilter?.let { g -> list.filter { it.group == g } } ?: list } + val channels = when { + state.favoritesOnly -> allChannels.filter { it.backendId in favorites } + state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter } + else -> allChannels + } if (allChannels.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -66,73 +73,136 @@ fun LiveScreen(state: AppState) { return } - Column(Modifier.fillMaxSize()) { - LazyRow( - modifier = Modifier.padding(bottom = 6.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - item { - FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) { - state.favoritesOnly = false - state.groupFilter = null - } - } - if (isDispatcharr) { + Row(Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .width(264.dp) + .fillMaxHeight(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = 28.dp, end = 12.dp, top = 4.dp, bottom = 32.dp + ), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { item { - FilterChip("★ Favoriten", state.favoritesOnly) { - state.favoritesOnly = !state.favoritesOnly + GroupItem( + label = "Alle Sender", + count = allChannels.size, + selected = !state.favoritesOnly && state.groupFilter == null, + ) { + state.favoritesOnly = false + state.groupFilter = null + } + } + if (isDispatcharr) { + item { + GroupItem( + label = "★ Favoriten", + count = favorites.size, + selected = state.favoritesOnly, + ) { + state.favoritesOnly = true + state.groupFilter = null + } + } + } + item { + Box( + Modifier + .padding(horizontal = 14.dp, vertical = 8.dp) + .fillMaxWidth() + .height(1.dp) + .background(CastarrColors.line) + ) + } + items(groups, key = { it }) { group -> + GroupItem( + label = group, + count = remember(allChannels, group) { allChannels.count { it.group == group } }, + selected = state.groupFilter == group, + ) { + state.favoritesOnly = false + state.groupFilter = group } } } - items(groups, key = { it }) { group -> - FilterChip(group, state.groupFilter == group) { - state.groupFilter = if (state.groupFilter == group) null else group + + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp + ), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (channels.isEmpty()) { + item { + Text( + if (state.favoritesOnly) + "Noch keine Favoriten — halte OK auf einem Sender gedrückt." + else "Diese Gruppe ist leer.", + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 14.sp, + modifier = Modifier.padding(24.dp), + ) + } + } + items(channels, key = { it.url + it.name }) { channel -> + ChannelRow( + channel = channel, + index = channels.indexOf(channel), + showGroup = state.groupFilter == null, + nowNext = state.nowNext(channel), + playing = state.currentChannel?.url == channel.url, + favorite = isDispatcharr && channel.backendId in favorites, + epgStamp = epgStamp, + onLongClick = if (isDispatcharr) { + { state.dispatcharr.toggleFavorite(channel) } + } else null, + ) { state.play(channel) } } } } - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp - ), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(channels, key = { it.url + it.name }) { channel -> - ChannelRow( - channel = channel, - index = channels.indexOf(channel), - nowNext = state.nowNext(channel), - playing = state.currentChannel?.url == channel.url, - favorite = isDispatcharr && channel.backendId in favorites, - epgStamp = epgStamp, - onLongClick = if (isDispatcharr) { - { state.dispatcharr.toggleFavorite(channel) } - } else null, - ) { state.play(channel) } - } - } - } } @Composable -private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) { +private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: () -> Unit) { Surface( - onClick = onClick, - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), + // Focusing a group opens it right away (browse like a menu); + // clicking is not required, but harmless. + onClick = onSelect, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) onSelect() }, + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), colors = ClickableSurfaceDefaults.colors( - containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface, + containerColor = if (selected) CastarrColors.accentDim else Color.Transparent, contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, focusedContainerColor = CastarrColors.accent, focusedContentColor = CastarrColors.onAccent, ), ) { - Text( - label, - fontFamily = AppFont, - fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), - ) + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + fontFamily = AppFont, + fontSize = 14.sp, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Text( + "$count", + fontFamily = AppFont, + fontSize = 12.sp, + ) + } } } @@ -140,6 +210,7 @@ private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) { private fun ChannelRow( channel: Channel, index: Int, + showGroup: Boolean, nowNext: NowNext, playing: Boolean, favorite: Boolean, @@ -189,7 +260,7 @@ private fun ChannelRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (channel.group.isNotEmpty()) { + if (showGroup && channel.group.isNotEmpty()) { Text( channel.group, color = CastarrColors.faint,