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:
@@ -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
|
||||
|
||||
@@ -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?)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)})",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user