fix: settings option chips (blob layout), real gear icon, overlay EPG filter, installer on main thread

- 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 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 00:55:47 +02:00
parent deca5bd0cb
commit 5de71193ad
7 changed files with 91 additions and 48 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 14 versionCode = 15
versionName = "0.7.1" versionName = "0.7.2"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -9,6 +9,12 @@ import java.util.Locale
data class Programme(val start: Long, val stop: Long, val title: String) 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?) data class NowNext(val now: Programme?, val next: Programme?)
/** /**

View File

@@ -155,22 +155,29 @@ private fun GearButton(onClick: () -> Unit) {
) { ) {
val color = LocalContentColor.current val color = LocalContentColor.current
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.size(17.dp)) { Canvas(Modifier.size(18.dp)) {
val stroke = 1.7.dp.toPx()
val center = Offset(size.width / 2f, size.height / 2f) val center = Offset(size.width / 2f, size.height / 2f)
drawCircle(color, radius = size.minDimension * 0.17f, center = center, style = Stroke(stroke)) // Thick ring with a hole plus stubby teeth — thin lines
val inner = size.minDimension * 0.30f // read as a sun, not a gear.
val outer = size.minDimension * 0.48f 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) { for (i in 0 until 8) {
val angle = i * (PI / 4) val angle = (i + 0.5) * (PI / 4)
val dx = cos(angle).toFloat() val dx = cos(angle).toFloat()
val dy = sin(angle).toFloat() val dy = sin(angle).toFloat()
drawLine( drawLine(
color, color,
start = center + Offset(dx * inner, dy * inner), start = center + Offset(dx * toothStart, dy * toothStart),
end = center + Offset(dx * outer, dy * outer), end = center + Offset(dx * toothEnd, dy * toothEnd),
strokeWidth = stroke, strokeWidth = size.minDimension * 0.17f,
cap = StrokeCap.Round, cap = StrokeCap.Butt,
) )
} }
} }

View File

@@ -41,6 +41,7 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel import dev.castarr.tv.playlist.Channel
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -335,19 +336,13 @@ private fun ChannelRow(
private fun formatClock(millis: Long): String = private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) 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 * 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). * under the title, next programme only when it differs (design review P1/P2).
*/ */
@Composable @Composable
private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) { 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) { Column(modifier) {
if (now != null) { if (now != null) {
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
@@ -386,7 +381,7 @@ private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
) )
} }
val next = nowNext.next?.takeUnless { val next = nowNext.next?.takeUnless {
isPlaceholder(it.title) || it.title == now.title isEpgPlaceholder(it.title) || it.title == now.title
} }
if (next != null) { if (next != null) {
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))

View File

@@ -47,6 +47,7 @@ import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import dev.castarr.tv.data.isEpgPlaceholder
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -191,7 +192,7 @@ private fun Overlay(state: AppState) {
) )
val info = state.currentChannel?.let { state.nowNext(it) } val info = state.currentChannel?.let { state.nowNext(it) }
val now = info?.now val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
if (now != null) { if (now != null) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.Bottom) { 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)) Spacer(Modifier.height(2.dp))
Text( Text(
"Danach: ${next.title} (${formatClock(next.start)})", "Danach: ${next.title} (${formatClock(next.start)})",

View File

@@ -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 @Composable
private fun SegmentedRow( private fun SegmentedRow(
label: String, label: String,
@@ -325,16 +327,48 @@ private fun SegmentedRow(
selected: Int, selected: Int,
onChange: (Int) -> Unit, onChange: (Int) -> Unit,
) { ) {
SettingRow( Surface(
label = label, onClick = { onChange((selected + 1) % options.size) },
subtitle = subtitle, modifier = Modifier
trailing = { .fillMaxWidth()
Row( .onPreviewKeyEvent { event ->
Modifier event.type == KeyEventType.KeyDown && when {
.clip(RoundedCornerShape(999.dp)) event.key == Key.DirectionLeft && selected > 0 -> {
.background(CastarrColors.bg) onChange(selected - 1); true
.border(1.dp, CastarrColors.line, RoundedCornerShape(999.dp)) }
.padding(3.dp) 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 -> options.forEachIndexed { index, option ->
Box( Box(
@@ -342,7 +376,12 @@ private fun SegmentedRow(
.clip(RoundedCornerShape(999.dp)) .clip(RoundedCornerShape(999.dp))
.background( .background(
if (index == selected) CastarrColors.accent 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) .padding(horizontal = 12.dp, vertical = 5.dp)
) { ) {
@@ -352,25 +391,16 @@ private fun SegmentedRow(
else CastarrColors.muted, else CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (index == selected) FontWeight.SemiBold fontWeight = if (index == selected) FontWeight.SemiBold
else FontWeight.Normal, 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 @Composable

View File

@@ -69,7 +69,9 @@ object UpdateChecker {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) 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 null
}.getOrElse { }.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}") Log.w(TAG, "install failed: ${it.javaClass.simpleName}")