Live screen: group rail layout, exclusive filtering, cleaner top bar

- 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 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 00:07:23 +02:00
parent 00762b6cbb
commit b04240ffde
2 changed files with 142 additions and 63 deletions

View File

@@ -27,6 +27,14 @@ import dev.castarr.tv.AppState
@Composable @Composable
fun CastarrApp(state: AppState) { 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -65,14 +73,6 @@ private fun TopBar(state: AppState) {
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
letterSpacing = 4.sp, 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)) Spacer(Modifier.weight(1f))
state.connectedRemote?.let { name -> state.connectedRemote?.let { name ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -85,6 +85,14 @@ private fun TopBar(state: AppState) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp) 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
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.LazyRow
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
@@ -23,6 +23,7 @@ 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.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
import androidx.compose.ui.text.style.TextOverflow 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.data.NowNext
import dev.castarr.tv.playlist.Channel 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 @Composable
fun LiveScreen(state: AppState) { fun LiveScreen(state: AppState) {
val genericChannels by state.source.channels.collectAsStateWithLifecycle() val genericChannels by state.source.channels.collectAsStateWithLifecycle()
@@ -50,9 +55,11 @@ fun LiveScreen(state: AppState) {
val groups = remember(allChannels) { val groups = remember(allChannels) {
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted() allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
} }
val channels = allChannels val channels = when {
.let { list -> if (isDispatcharr && state.favoritesOnly) list.filter { it.backendId in favorites } else list } state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
.let { list -> state.groupFilter?.let { g -> list.filter { it.group == g } } ?: list } state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
}
if (allChannels.isEmpty()) { if (allChannels.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -66,73 +73,136 @@ fun LiveScreen(state: AppState) {
return return
} }
Column(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
LazyRow( LazyColumn(
modifier = Modifier.padding(bottom = 6.dp), modifier = Modifier
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp), .width(264.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp), .fillMaxHeight(),
) { contentPadding = androidx.compose.foundation.layout.PaddingValues(
item { start = 28.dp, end = 12.dp, top = 4.dp, bottom = 32.dp
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) { ),
state.favoritesOnly = false verticalArrangement = Arrangement.spacedBy(2.dp),
state.groupFilter = null ) {
}
}
if (isDispatcharr) {
item { item {
FilterChip("★ Favoriten", state.favoritesOnly) { GroupItem(
state.favoritesOnly = !state.favoritesOnly 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) { LazyColumn(
state.groupFilter = if (state.groupFilter == group) null else group 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 @Composable
private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) { private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: () -> Unit) {
Surface( Surface(
onClick = onClick, // Focusing a group opens it right away (browse like a menu);
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), // 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( 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, contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent, focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent, focusedContentColor = CastarrColors.onAccent,
), ),
) { ) {
Text( Row(
label, modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
fontFamily = AppFont, verticalAlignment = Alignment.CenterVertically,
fontSize = 12.sp, ) {
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), 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( private fun ChannelRow(
channel: Channel, channel: Channel,
index: Int, index: Int,
showGroup: Boolean,
nowNext: NowNext, nowNext: NowNext,
playing: Boolean, playing: Boolean,
favorite: Boolean, favorite: Boolean,
@@ -189,7 +260,7 @@ private fun ChannelRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
if (channel.group.isNotEmpty()) { if (showGroup && channel.group.isNotEmpty()) {
Text( Text(
channel.group, channel.group,
color = CastarrColors.faint, color = CastarrColors.faint,