From b59c3deee48eb5c07b8117cb60da9e46185418a0 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 26 Aug 2026 00:27:55 +0200 Subject: [PATCH] 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 --- app/build.gradle.kts | 4 +- app/src/main/java/dev/castarr/tv/AppState.kt | 3 + .../main/java/dev/castarr/tv/MainActivity.kt | 30 +++++ .../main/java/dev/castarr/tv/ui/CastarrApp.kt | 40 ++++++- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 112 ++++++++++++------ .../java/dev/castarr/tv/ui/PlayerScreen.kt | 39 +++--- 6 files changed, 161 insertions(+), 67 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aff0440..f969f36 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 10 - versionName = "0.5.0" + versionCode = 11 + versionName = "0.6.0" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index 77c75c0..353aa64 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -42,6 +42,9 @@ class AppState( private set 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 * auto-hide timer. */ var overlayPing by mutableLongStateOf(0L) diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index 29f7b24..d6c5740 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -198,6 +198,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { // --- TV remote keys during playback (TV-PC / TV-PP) --- 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) { when (keyCode) { // OK opens the control overlay (buttons take over from @@ -261,6 +270,26 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { 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) { state.overlayVisible = true state.cycleAudio(direction) @@ -293,5 +322,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { private companion object { const val TICK_INTERVAL_MS = 2_000L const val SEEK_STEP_SECONDS = 10L + const val DIGIT_COMMIT_MS = 1_800L } } diff --git a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt index 9a643bc..1301ef8 100644 --- a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt +++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,6 +57,41 @@ fun CastarrApp(state: AppState) { if (state.playerVisible) { 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, shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), 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, focusedContainerColor = CastarrColors.accent, focusedContentColor = CastarrColors.onAccent, diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index b2d2d27..131a617 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -36,6 +36,9 @@ import androidx.tv.material3.Text import dev.castarr.tv.AppState import dev.castarr.tv.data.NowNext 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, @@ -151,11 +154,16 @@ fun LiveScreen(state: AppState) { items(channels, key = { it.url + it.name }) { channel -> ChannelRow( 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, nowNext = state.nowNext(channel), 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, onLongClick = if (isDispatcharr) { { state.dispatcharr.toggleFavorite(channel) } @@ -209,7 +217,7 @@ private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: () @Composable private fun ChannelRow( channel: Channel, - index: Int, + number: Int, showGroup: Boolean, nowNext: NowNext, playing: Boolean, @@ -235,31 +243,30 @@ private fun ChannelRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - "${index + 1}", + "$number", color = if (playing) CastarrColors.accent else CastarrColors.faint, fontFamily = AppFont, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.width(44.dp), ) - if (favorite) { - Text( - "★", - color = CastarrColors.accent, - fontSize = 13.sp, - modifier = Modifier.width(20.dp), - ) - } - Column(Modifier.weight(1f)) { - Text( - channel.name, - color = if (playing) CastarrColors.accent else CastarrColors.fg, - fontFamily = AppFont, - fontSize = 16.sp, - fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column(Modifier.width(250.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + channel.name, + color = if (playing) CastarrColors.accent else CastarrColors.fg, + fontFamily = AppFont, + fontSize = 16.sp, + fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + 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()) { Text( channel.group, @@ -271,7 +278,8 @@ private fun ChannelRow( ) } } - NowNextCell(nowNext) + Spacer(Modifier.width(24.dp)) + EpgCell(nowNext, Modifier.weight(1f)) if (playing) { Spacer(Modifier.width(12.dp)) 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 -private fun NowNextCell(nowNext: NowNext) { - val now = nowNext.now - Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) { +private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) { + val now = nowNext.now?.takeUnless { isPlaceholder(it.title) } + Column(modifier) { if (now != null) { - Text( - now.title, - color = CastarrColors.muted, - fontFamily = AppFont, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.Bottom) { + Text( + now.title, + color = CastarrColors.fg, + fontFamily = AppFont, + fontSize = 14.sp, + maxLines = 1, + 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() / (now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f) - Spacer(Modifier.height(5.dp)) + Spacer(Modifier.height(6.dp)) Box( Modifier - .width(180.dp) + .fillMaxWidth() .height(2.dp) .clip(RoundedCornerShape(1.dp)) .background(CastarrColors.line) @@ -315,13 +346,16 @@ private fun NowNextCell(nowNext: NowNext) { .background(CastarrColors.accent) ) } - nowNext.next?.let { next -> - Spacer(Modifier.height(4.dp)) + val next = nowNext.next?.takeUnless { + isPlaceholder(it.title) || it.title == now.title + } + if (next != null) { + Spacer(Modifier.height(5.dp)) Text( "danach: ${next.title}", color = CastarrColors.faint, fontFamily = AppFont, - fontSize = 11.sp, + fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt index 83fadac..3d5b129 100644 --- a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt @@ -142,37 +142,26 @@ private fun Overlay(state: AppState) { .padding(horizontal = 36.dp) .padding(bottom = 24.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (state.isLive) { - Box( - Modifier - .size(6.dp) - .clip(CircleShape) - .background(CastarrColors.live) - ) - Spacer(Modifier.width(8.dp)) + // No status word while simply playing, and no LIVE badge — a + // red dot reads as "recording" (user feedback). + val statusWord = when (state.playerState) { + "paused" -> "Pausiert" + "buffering" -> "Lädt…" + "error" -> "Wiedergabefehler" + else -> null + } + if (statusWord != null) { + Row { + Spacer(Modifier.weight(1f)) Text( - "LIVE", + statusWord, color = CastarrColors.muted, fontFamily = AppFont, - fontSize = 11.sp, - letterSpacing = 2.sp, + fontSize = 12.sp, ) } - Spacer(Modifier.weight(1f)) - Text( - when (state.playerState) { - "paused" -> "Pausiert" - "buffering" -> "Lädt…" - "error" -> "Wiedergabefehler" - else -> "Wird abgespielt" - }, - color = CastarrColors.muted, - fontFamily = AppFont, - fontSize = 12.sp, - ) + Spacer(Modifier.height(4.dp)) } - Spacer(Modifier.height(4.dp)) Text( state.currentChannel?.name.orEmpty(), color = CastarrColors.fg,