Channel numbers on the remote pad, EPG column redesign, calmer overlay

- Number keys type the stable channel number (position in Alle Sender,
  shown identically in every view) with a live feedback badge; commits
  after a short pause or as soon as no longer number can match.
- EPG cell is a left-aligned column with a fixed leading edge: title +
  time range, progress under the title, next line only when it differs;
  placeholder programmes (kein Programm / no information) count as no
  EPG data. Star hidden in the favorites view.
- Player overlay: no LIVE dot (reads as recording) and no status word
  while playing; state text only for paused/buffering/error.
- Settings pill gets a resting surface so it reads as a target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 00:27:55 +02:00
parent 8d6958536d
commit d98f277ec4
6 changed files with 161 additions and 67 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 = 10 versionCode = 11
versionName = "0.5.0" versionName = "0.6.0"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -42,6 +42,9 @@ class AppState(
private set private set
private var lastNowTitle: String? = null private var lastNowTitle: String? = null
/** Digits typed on the remote's number pad (channel switching). */
var digitBuffer by mutableStateOf("")
/** Bumped on every interaction with the visible overlay to restart the /** Bumped on every interaction with the visible overlay to restart the
* auto-hide timer. */ * auto-hide timer. */
var overlayPing by mutableLongStateOf(0L) var overlayPing by mutableLongStateOf(0L)

View File

@@ -198,6 +198,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
// --- TV remote keys during playback (TV-PC / TV-PP) --- // --- TV remote keys during playback (TV-PC / TV-PP) ---
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// Number pad: type the channel number shown in the list (position in
// "Alle Sender"), switch after a short pause or as soon as no longer
// number can match. Ignored on screens with text fields.
if (keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 &&
state.screen != AppState.Screen.WELCOME && state.screen != AppState.Screen.ADVANCED
) {
onDigit(keyCode - KeyEvent.KEYCODE_0)
return true
}
if (state.playerVisible) { if (state.playerVisible) {
when (keyCode) { when (keyCode) {
// OK opens the control overlay (buttons take over from // OK opens the control overlay (buttons take over from
@@ -261,6 +270,26 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }
private val digitCommit = Runnable { commitDigits() }
private fun onDigit(digit: Int) {
if (state.digitBuffer.length >= 4) return
state.digitBuffer += digit.toString()
mainHandler.removeCallbacks(digitCommit)
val number = state.digitBuffer.toIntOrNull() ?: return commitDigits()
// Another digit could still form a valid number? Then wait for it.
if (number * 10 > state.activeChannels().size) commitDigits()
else mainHandler.postDelayed(digitCommit, DIGIT_COMMIT_MS)
}
private fun commitDigits() {
mainHandler.removeCallbacks(digitCommit)
val number = state.digitBuffer.toIntOrNull()
state.digitBuffer = ""
val channel = number?.let { state.activeChannels().getOrNull(it - 1) } ?: return
state.play(channel)
}
private fun cycleAudioWithOverlay(direction: Int) { private fun cycleAudioWithOverlay(direction: Int) {
state.overlayVisible = true state.overlayVisible = true
state.cycleAudio(direction) state.cycleAudio(direction)
@@ -293,5 +322,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
private companion object { private companion object {
const val TICK_INTERVAL_MS = 2_000L const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
} }
} }

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -56,6 +57,41 @@ fun CastarrApp(state: AppState) {
if (state.playerVisible) { if (state.playerVisible) {
PlayerScreen(state) PlayerScreen(state)
} }
if (state.digitBuffer.isNotEmpty()) {
DigitBadge(state)
}
}
}
/** Big feedback badge while typing a channel number on the remote. */
@Composable
private fun DigitBadge(state: AppState) {
val target = state.digitBuffer.toIntOrNull()
?.let { state.activeChannels().getOrNull(it - 1) }
Column(
modifier = Modifier
.padding(top = 60.dp, end = 48.dp)
.fillMaxSize()
.wrapContentSize(Alignment.TopEnd)
.clip(RoundedCornerShape(14.dp))
.background(CastarrColors.surface)
.padding(horizontal = 22.dp, vertical = 14.dp),
horizontalAlignment = Alignment.End,
) {
Text(
state.digitBuffer,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 34.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 2.sp,
)
Text(
target?.name ?: "kein Sender",
color = if (target != null) CastarrColors.muted else CastarrColors.faint,
fontFamily = AppFont,
fontSize = 13.sp,
)
} }
} }
@@ -103,7 +139,9 @@ private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent, // Visible resting surface, so the pill reads as a target even
// without focus (design review P3).
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent, focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent, focusedContentColor = CastarrColors.onAccent,

View File

@@ -36,6 +36,9 @@ 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.playlist.Channel import dev.castarr.tv.playlist.Channel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** /**
* Live tab: group rail on the left (focusing a group opens it — exclusive, * Live tab: group rail on the left (focusing a group opens it — exclusive,
@@ -151,11 +154,16 @@ fun LiveScreen(state: AppState) {
items(channels, key = { it.url + it.name }) { channel -> items(channels, key = { it.url + it.name }) { channel ->
ChannelRow( ChannelRow(
channel = channel, channel = channel,
index = channels.indexOf(channel), // Stable channel number = position in "Alle Sender",
// matching the remote's number pad in every view.
number = allChannels.indexOf(channel) + 1,
showGroup = state.groupFilter == null, showGroup = state.groupFilter == null,
nowNext = state.nowNext(channel), nowNext = state.nowNext(channel),
playing = state.currentChannel?.url == channel.url, playing = state.currentChannel?.url == channel.url,
favorite = isDispatcharr && channel.backendId in favorites, // All rows are favorites in the favorites view — the
// star only carries meaning elsewhere.
favorite = isDispatcharr && !state.favoritesOnly &&
channel.backendId in favorites,
epgStamp = epgStamp, epgStamp = epgStamp,
onLongClick = if (isDispatcharr) { onLongClick = if (isDispatcharr) {
{ state.dispatcharr.toggleFavorite(channel) } { state.dispatcharr.toggleFavorite(channel) }
@@ -209,7 +217,7 @@ private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: ()
@Composable @Composable
private fun ChannelRow( private fun ChannelRow(
channel: Channel, channel: Channel,
index: Int, number: Int,
showGroup: Boolean, showGroup: Boolean,
nowNext: NowNext, nowNext: NowNext,
playing: Boolean, playing: Boolean,
@@ -235,22 +243,15 @@ private fun ChannelRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
"${index + 1}", "$number",
color = if (playing) CastarrColors.accent else CastarrColors.faint, color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.width(44.dp), modifier = Modifier.width(44.dp),
) )
if (favorite) { Column(Modifier.width(250.dp)) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
"",
color = CastarrColors.accent,
fontSize = 13.sp,
modifier = Modifier.width(20.dp),
)
}
Column(Modifier.weight(1f)) {
Text( Text(
channel.name, channel.name,
color = if (playing) CastarrColors.accent else CastarrColors.fg, color = if (playing) CastarrColors.accent else CastarrColors.fg,
@@ -259,7 +260,13 @@ private fun ChannelRow(
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
if (favorite) {
Spacer(Modifier.width(8.dp))
Text("", color = CastarrColors.accent, fontSize = 13.sp)
}
}
if (showGroup && channel.group.isNotEmpty()) { if (showGroup && channel.group.isNotEmpty()) {
Text( Text(
channel.group, channel.group,
@@ -271,7 +278,8 @@ private fun ChannelRow(
) )
} }
} }
NowNextCell(nowNext) Spacer(Modifier.width(24.dp))
EpgCell(nowNext, Modifier.weight(1f))
if (playing) { if (playing) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Box( Box(
@@ -285,25 +293,48 @@ 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 @Composable
private fun NowNextCell(nowNext: NowNext) { private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
val now = nowNext.now val now = nowNext.now?.takeUnless { isPlaceholder(it.title) }
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) { Column(modifier) {
if (now != null) { if (now != null) {
Row(verticalAlignment = Alignment.Bottom) {
Text( Text(
now.title, now.title,
color = CastarrColors.muted, color = CastarrColors.fg,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 13.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
Spacer(Modifier.width(12.dp))
Text(
"${formatClock(now.start)}${formatClock(now.stop)}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
val fraction = ((System.currentTimeMillis() - now.start).toFloat() / val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f) (now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(6.dp))
Box( Box(
Modifier Modifier
.width(180.dp) .fillMaxWidth()
.height(2.dp) .height(2.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line) .background(CastarrColors.line)
@@ -315,13 +346,16 @@ private fun NowNextCell(nowNext: NowNext) {
.background(CastarrColors.accent) .background(CastarrColors.accent)
) )
} }
nowNext.next?.let { next -> val next = nowNext.next?.takeUnless {
Spacer(Modifier.height(4.dp)) isPlaceholder(it.title) || it.title == now.title
}
if (next != null) {
Spacer(Modifier.height(5.dp))
Text( Text(
"danach: ${next.title}", "danach: ${next.title}",
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 11.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )

View File

@@ -142,37 +142,26 @@ private fun Overlay(state: AppState) {
.padding(horizontal = 36.dp) .padding(horizontal = 36.dp)
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { // No status word while simply playing, and no LIVE badge — a
if (state.isLive) { // red dot reads as "recording" (user feedback).
Box( val statusWord = when (state.playerState) {
Modifier
.size(6.dp)
.clip(CircleShape)
.background(CastarrColors.live)
)
Spacer(Modifier.width(8.dp))
Text(
"LIVE",
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 11.sp,
letterSpacing = 2.sp,
)
}
Spacer(Modifier.weight(1f))
Text(
when (state.playerState) {
"paused" -> "Pausiert" "paused" -> "Pausiert"
"buffering" -> "Lädt…" "buffering" -> "Lädt…"
"error" -> "Wiedergabefehler" "error" -> "Wiedergabefehler"
else -> "Wird abgespielt" else -> null
}, }
if (statusWord != null) {
Row {
Spacer(Modifier.weight(1f))
Text(
statusWord,
color = CastarrColors.muted, color = CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
}
Text( Text(
state.currentChannel?.name.orEmpty(), state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg, color = CastarrColors.fg,