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