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
|
@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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,42 +73,86 @@ 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(
|
||||||
|
start = 28.dp, end = 12.dp, top = 4.dp, bottom = 32.dp
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) {
|
GroupItem(
|
||||||
|
label = "Alle Sender",
|
||||||
|
count = allChannels.size,
|
||||||
|
selected = !state.favoritesOnly && state.groupFilter == null,
|
||||||
|
) {
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isDispatcharr) {
|
if (isDispatcharr) {
|
||||||
item {
|
item {
|
||||||
FilterChip("★ Favoriten", state.favoritesOnly) {
|
GroupItem(
|
||||||
state.favoritesOnly = !state.favoritesOnly
|
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 ->
|
items(groups, key = { it }) { group ->
|
||||||
FilterChip(group, state.groupFilter == group) {
|
GroupItem(
|
||||||
state.groupFilter = if (state.groupFilter == group) null else group
|
label = group,
|
||||||
|
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
||||||
|
selected = state.groupFilter == group,
|
||||||
|
) {
|
||||||
|
state.favoritesOnly = false
|
||||||
|
state.groupFilter = group
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||||
start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp
|
start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.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 ->
|
items(channels, key = { it.url + it.name }) { channel ->
|
||||||
ChannelRow(
|
ChannelRow(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
index = channels.indexOf(channel),
|
index = channels.indexOf(channel),
|
||||||
|
showGroup = state.groupFilter == null,
|
||||||
nowNext = state.nowNext(channel),
|
nowNext = state.nowNext(channel),
|
||||||
playing = state.currentChannel?.url == channel.url,
|
playing = state.currentChannel?.url == channel.url,
|
||||||
favorite = isDispatcharr && channel.backendId in favorites,
|
favorite = isDispatcharr && channel.backendId in favorites,
|
||||||
@@ -116,23 +167,42 @@ fun LiveScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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,
|
||||||
),
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user