From 7c1f8d2c8b98b93f546c7e7cf2a57b387d040a69 Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 01:12:07 +0200 Subject: [PATCH] Channel rows with real logos (Entwurf A), no focus scale clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rows carry the Dispatcharr channel logo in a small tile (initials as fallback), sit on a surface instead of transparent black, and get the accent focus border; the group subline is gone — the logo carries channel identity. - Focus scale disabled on settings rows, picker options, rail items and channel rows: the grown surface got clipped by its parent. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 5 +- .../castarr/tv/data/DispatcharrRepository.kt | 9 +- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 106 +++++++++++++----- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 2 + 4 files changed, 88 insertions(+), 34 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f6b89df..1b6b82d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 17 - versionName = "0.8.1" + versionCode = 18 + versionName = "0.8.2" } // Release signing from environment (see ~/.keys/castarr-release.env on the @@ -81,6 +81,7 @@ dependencies { implementation("org.nanohttpd:nanohttpd:2.3.1") implementation("org.nanohttpd:nanohttpd-websocket:2.3.1") implementation("com.google.zxing:core:3.5.3") + implementation("io.coil-kt:coil-compose:2.7.0") } // Full JDK for javac via toolchain (host may only have a JRE); resolved by diff --git a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt index 8ff154c..d57f06f 100644 --- a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt +++ b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt @@ -126,7 +126,14 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) { name = obj.optString("effective_name").ifEmpty { obj.optString("name") }, url = streamUrl(uuid), group = groups[obj.optInt("channel_group_id")].orEmpty(), - logo = "", + // logo-cache endpoint is AllowAny — loads without auth. + logo = obj.optJSONObject("logo")?.optString("cache_url").orEmpty().let { + when { + it.isEmpty() || it == "null" -> "" + it.startsWith("http") -> it + else -> auth.serverUrl.trimEnd('/') + it + } + }, tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") }, backendId = id, streamKey = uuid, 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 83245b3..e5e0c80 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -1,5 +1,6 @@ package dev.castarr.tv.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -36,6 +37,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.SubcomposeAsyncImage +import androidx.compose.ui.layout.ContentScale +import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Surface import androidx.tv.material3.Text @@ -166,7 +170,7 @@ fun LiveScreen(state: AppState) { contentPadding = androidx.compose.foundation.layout.PaddingValues( start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp ), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { if (channels.isEmpty()) { item { @@ -190,7 +194,6 @@ fun LiveScreen(state: AppState) { modifier = Modifier .focusProperties { left = railFocus } .then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier), - showGroup = state.groupFilter == null, nowNext = state.nowNext(channel), playing = state.currentChannel?.url == channel.url, // All rows are favorites in the favorites view — the @@ -223,6 +226,7 @@ private fun GroupItem( .fillMaxWidth() .onFocusChanged { if (it.isFocused) onSelect() }, shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( containerColor = if (selected) CastarrColors.accentDim else Color.Transparent, contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, @@ -258,7 +262,6 @@ private fun ChannelRow( channel: Channel, number: Int, modifier: Modifier = Modifier, - showGroup: Boolean, nowNext: NowNext, playing: Boolean, favorite: Boolean, @@ -271,12 +274,20 @@ private fun ChannelRow( onLongClick = onLongClick, modifier = modifier.fillMaxWidth(), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + // No focus scale — grown rows get clipped at the pane edges. + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( - containerColor = if (playing) CastarrColors.accentDim else Color.Transparent, + containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface, contentColor = CastarrColors.fg, focusedContainerColor = CastarrColors.surfaceFocused, focusedContentColor = CastarrColors.fg, ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, CastarrColors.accent), + shape = RoundedCornerShape(12.dp), + ), + ), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), @@ -288,34 +299,27 @@ private fun ChannelRow( fontFamily = AppFont, fontSize = 14.sp, fontWeight = FontWeight.Medium, - modifier = Modifier.width(44.dp), + modifier = Modifier.width(40.dp), ) - Column(Modifier.width(250.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - channel.name, - color = if (playing) CastarrColors.accent else CastarrColors.fg, - fontFamily = AppFont, - fontSize = 16.sp, - fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - if (favorite) { - Spacer(Modifier.width(8.dp)) - Text("★", color = CastarrColors.accent, fontSize = 13.sp) - } - } - if (showGroup && channel.group.isNotEmpty()) { - Text( - channel.group, - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + LogoTile(channel) + Spacer(Modifier.width(14.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.width(230.dp), + ) { + Text( + channel.name, + color = if (playing) CastarrColors.accent else CastarrColors.fg, + fontFamily = AppFont, + fontSize = 16.sp, + fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (favorite) { + Spacer(Modifier.width(8.dp)) + Text("★", color = CastarrColors.accent, fontSize = 13.sp) } } Spacer(Modifier.width(24.dp)) @@ -333,6 +337,46 @@ private fun ChannelRow( } } +/** Channel logo in a small tile; initials while loading or without a logo. */ +@Composable +private fun LogoTile(channel: Channel) { + val initials = channel.name.split(" ").filter { it.isNotBlank() } + .take(2).map { it.first() }.joinToString("").uppercase() + Box( + Modifier + .size(38.dp) + .clip(RoundedCornerShape(8.dp)) + .background(CastarrColors.surfaceFocused), + contentAlignment = Alignment.Center, + ) { + if (channel.logo.isNotEmpty()) { + SubcomposeAsyncImage( + model = channel.logo, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().padding(5.dp), + loading = { LogoInitials(initials) }, + error = { LogoInitials(initials) }, + ) + } else { + LogoInitials(initials) + } + } +} + +@Composable +private fun LogoInitials(initials: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + initials, + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + private fun formatClock(millis: Long): String = SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index d5cb843..efca763 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -297,6 +297,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) { .fillMaxWidth() .then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier), shape = ClickableSurfaceDefaults.shape(rowShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( containerColor = androidx.compose.ui.graphics.Color.Transparent, contentColor = CastarrColors.fg, @@ -405,6 +406,7 @@ private fun SettingRow( onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = ClickableSurfaceDefaults.shape(rowShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), colors = ClickableSurfaceDefaults.colors( containerColor = androidx.compose.ui.graphics.Color.Transparent, contentColor = CastarrColors.fg,