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"
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

View File

@@ -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?)
/**

View File

@@ -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,
)
}
}

View File

@@ -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))

View File

@@ -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)})",

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
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

View File

@@ -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}")