From 5de71193adccc3d1647688440a76f36fb3f5ca2e Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 00:55:47 +0200 Subject: [PATCH] fix: settings option chips (blob layout), real gear icon, overlay EPG filter, installer on main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SegmentedRow lays label above wrapping option chips instead of squeezing them into the trailing slot (long profile names collapsed the label and blew the pill up into a blob). - Gear icon: thick ring + stubby teeth instead of sun rays. - Player overlay filters EPG placeholders and next==now duplicates like the channel list. - Updater: startActivity from Dispatchers.Main — firing it from the IO dispatcher stalled the first install attempt. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 4 +- .../main/java/dev/castarr/tv/data/Xmltv.kt | 6 ++ .../main/java/dev/castarr/tv/ui/CastarrApp.kt | 27 ++++--- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 11 +-- .../java/dev/castarr/tv/ui/PlayerScreen.kt | 7 +- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 80 +++++++++++++------ .../dev/castarr/tv/update/UpdateChecker.kt | 4 +- 7 files changed, 91 insertions(+), 48 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28a4f63..243f51b 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 = 14 - versionName = "0.7.1" + versionCode = 15 + versionName = "0.7.2" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/data/Xmltv.kt b/app/src/main/java/dev/castarr/tv/data/Xmltv.kt index 2025f01..83e71f8 100644 --- a/app/src/main/java/dev/castarr/tv/data/Xmltv.kt +++ b/app/src/main/java/dev/castarr/tv/data/Xmltv.kt @@ -9,6 +9,12 @@ import java.util.Locale data class Programme(val start: Long, val stop: Long, val title: String) +/** Provider EPGs pad gaps with placeholder programmes — treat as no data. */ +fun isEpgPlaceholder(title: String): Boolean { + val t = title.lowercase() + return "kein programm" in t || "keine information" in t || "no information" in t +} + data class NowNext(val now: Programme?, val next: Programme?) /** 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 cc64dff..1eb59a2 100644 --- a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt +++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt @@ -155,22 +155,29 @@ private fun GearButton(onClick: () -> Unit) { ) { val color = LocalContentColor.current Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { - Canvas(Modifier.size(17.dp)) { - val stroke = 1.7.dp.toPx() + Canvas(Modifier.size(18.dp)) { val center = Offset(size.width / 2f, size.height / 2f) - drawCircle(color, radius = size.minDimension * 0.17f, center = center, style = Stroke(stroke)) - val inner = size.minDimension * 0.30f - val outer = size.minDimension * 0.48f + // Thick ring with a hole plus stubby teeth — thin lines + // read as a sun, not a gear. + val ringWidth = size.minDimension * 0.20f + drawCircle( + color, + radius = size.minDimension * 0.27f, + center = center, + style = Stroke(ringWidth), + ) + val toothStart = size.minDimension * 0.34f + val toothEnd = size.minDimension * 0.50f for (i in 0 until 8) { - val angle = i * (PI / 4) + val angle = (i + 0.5) * (PI / 4) val dx = cos(angle).toFloat() val dy = sin(angle).toFloat() drawLine( color, - start = center + Offset(dx * inner, dy * inner), - end = center + Offset(dx * outer, dy * outer), - strokeWidth = stroke, - cap = StrokeCap.Round, + start = center + Offset(dx * toothStart, dy * toothStart), + end = center + Offset(dx * toothEnd, dy * toothEnd), + strokeWidth = size.minDimension * 0.17f, + cap = StrokeCap.Butt, ) } } 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 08dab55..83245b3 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -41,6 +41,7 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.castarr.tv.AppState import dev.castarr.tv.data.NowNext +import dev.castarr.tv.data.isEpgPlaceholder import dev.castarr.tv.playlist.Channel import java.text.SimpleDateFormat import java.util.Date @@ -335,19 +336,13 @@ private fun ChannelRow( private fun formatClock(millis: Long): String = SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) -/** Provider EPGs pad gaps with placeholder programmes — treat as no data. */ -private fun isPlaceholder(title: String): Boolean { - val t = title.lowercase() - return "kein programm" in t || "keine information" in t || "no information" in t -} - /** * Left-aligned EPG column with a fixed leading edge: title + times, progress * under the title, next programme only when it differs (design review P1/P2). */ @Composable private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) { - val now = nowNext.now?.takeUnless { isPlaceholder(it.title) } + val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) } Column(modifier) { if (now != null) { Row(verticalAlignment = Alignment.Bottom) { @@ -386,7 +381,7 @@ private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) { ) } val next = nowNext.next?.takeUnless { - isPlaceholder(it.title) || it.title == now.title + isEpgPlaceholder(it.title) || it.title == now.title } if (next != null) { Spacer(Modifier.height(5.dp)) diff --git a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt index 377be07..22e7b37 100644 --- a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt @@ -47,6 +47,7 @@ import androidx.tv.material3.LocalContentColor import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.castarr.tv.AppState +import dev.castarr.tv.data.isEpgPlaceholder import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -191,7 +192,7 @@ private fun Overlay(state: AppState) { ) val info = state.currentChannel?.let { state.nowNext(it) } - val now = info?.now + val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) } if (now != null) { Spacer(Modifier.height(2.dp)) Row(verticalAlignment = Alignment.Bottom) { @@ -210,7 +211,9 @@ private fun Overlay(state: AppState) { ) } } - info?.next?.let { next -> + info?.next?.takeUnless { + isEpgPlaceholder(it.title) || it.title == now?.title + }?.let { next -> Spacer(Modifier.height(2.dp)) Text( "Danach: ${next.title} (${formatClock(next.start)})", 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 1f0e1f3..68ef0ec 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -316,7 +316,9 @@ private fun SettingRow( } } -/** Row whose value is a segmented control; left/right switches in place. */ +/** Row whose value is a set of option chips below the label; left/right + * switches in place, chips wrap when names are long. */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable private fun SegmentedRow( label: String, @@ -325,16 +327,48 @@ private fun SegmentedRow( selected: Int, onChange: (Int) -> Unit, ) { - SettingRow( - label = label, - subtitle = subtitle, - trailing = { - Row( - Modifier - .clip(RoundedCornerShape(999.dp)) - .background(CastarrColors.bg) - .border(1.dp, CastarrColors.line, RoundedCornerShape(999.dp)) - .padding(3.dp) + Surface( + onClick = { onChange((selected + 1) % options.size) }, + modifier = Modifier + .fillMaxWidth() + .onPreviewKeyEvent { event -> + event.type == KeyEventType.KeyDown && when { + event.key == Key.DirectionLeft && selected > 0 -> { + onChange(selected - 1); true + } + event.key == Key.DirectionRight && selected < options.lastIndex -> { + onChange(selected + 1); true + } + else -> false + } + }, + shape = ClickableSurfaceDefaults.shape(rowShape), + colors = ClickableSurfaceDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + contentColor = CastarrColors.fg, + focusedContainerColor = CastarrColors.surfaceFocused, + focusedContentColor = CastarrColors.fg, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, CastarrColors.accent), + shape = rowShape, + ), + ), + ) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Text( + label, + color = CastarrColors.fg, fontFamily = AppFont, fontSize = 15.sp, + ) + Text( + subtitle, + color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp, + ) + Spacer(Modifier.height(8.dp)) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { options.forEachIndexed { index, option -> Box( @@ -342,7 +376,12 @@ private fun SegmentedRow( .clip(RoundedCornerShape(999.dp)) .background( if (index == selected) CastarrColors.accent - else androidx.compose.ui.graphics.Color.Transparent + else CastarrColors.bg + ) + .border( + 1.dp, + if (index == selected) CastarrColors.accent else CastarrColors.line, + RoundedCornerShape(999.dp), ) .padding(horizontal = 12.dp, vertical = 5.dp) ) { @@ -352,25 +391,16 @@ private fun SegmentedRow( else CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, fontWeight = if (index == selected) FontWeight.SemiBold else FontWeight.Normal, ) } } } - }, - keyHandler = { key -> - when { - key == Key.DirectionLeft && selected > 0 -> { - onChange(selected - 1); true - } - key == Key.DirectionRight && selected < options.lastIndex -> { - onChange(selected + 1); true - } - else -> false - } - }, - ) { onChange((selected + 1) % options.size) } + } + } } @Composable diff --git a/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt index 47751c4..d3bb52c 100644 --- a/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt +++ b/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt @@ -69,7 +69,9 @@ object UpdateChecker { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) } - context.startActivity(intent) + // Launching from the IO dispatcher stalled the installer on + // the first attempt — activities start from the main thread. + withContext(Dispatchers.Main) { context.startActivity(intent) } null }.getOrElse { Log.w(TAG, "install failed: ${it.javaClass.simpleName}")