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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user