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