Some checks failed
Build TV app / build (push) Failing after 2s
Control server security and robustness: - Socket read timeout, handshake deadline and a client cap so an idle or hostile connection can no longer pin threads forever (#1) - Per-address rate limiting that counts every failed hello, Origin checking on the upgrade, and a separate revocable session token for code-authenticated clients so the guessable path no longer yields the QR credential (#2) - Credentials excluded from cloud backup and device transfer, constant time comparisons, and a pairing reset in the settings (#3) - Playlist fetches restricted to http(s), capped at 24 MB and bounded by an overall transfer deadline (#4) - Port conflicts and MediaSession id collisions no longer crash the app; the remote degrades to unavailable with a plain-language note (#11) Player: - Seeking no longer collapses to position 0 when the duration is unknown (#5) - Pause acts on playWhenReady, so pausing during a stall works and playback cannot resume in the background after leaving the app (#6) - Playback failures stay on screen with a retry action instead of silently dropping back to the list (#7) - Reconnects are spaced 1s/3s/8s and re-entering the channel just closed waits out a short grace period, which is what the provider needs to release the previous session (#12) Channel list and remote: - Leaving playback returns to the channel the viewer came from (#13) - The remote only rebuilds its list when the data changed, never overwrites a focused input and carries indices instead of scanning (#8) - Pairing retry reconnects properly, resets its backoff and validates the code before spending an attempt (#9) - M3U parsing keeps commas in names, strips a BOM and rejects payloads that are not playlists, covered by unit tests under tests/ (#10) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
404 lines
16 KiB
Kotlin
404 lines
16 KiB
Kotlin
package dev.castarr.tv.ui
|
||
|
||
import androidx.compose.foundation.Canvas
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.focusable
|
||
import androidx.compose.foundation.layout.Box
|
||
import androidx.compose.foundation.layout.Column
|
||
import androidx.compose.foundation.layout.Row
|
||
import androidx.compose.foundation.layout.Spacer
|
||
import androidx.compose.foundation.layout.fillMaxSize
|
||
import androidx.compose.foundation.layout.fillMaxWidth
|
||
import androidx.compose.foundation.layout.height
|
||
import androidx.compose.foundation.layout.padding
|
||
import androidx.compose.foundation.layout.size
|
||
import androidx.compose.foundation.layout.width
|
||
import androidx.compose.foundation.shape.CircleShape
|
||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.LaunchedEffect
|
||
import androidx.compose.runtime.getValue
|
||
import androidx.compose.runtime.mutableStateOf
|
||
import androidx.compose.runtime.remember
|
||
import androidx.compose.runtime.setValue
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.draw.clip
|
||
import androidx.compose.ui.focus.FocusRequester
|
||
import androidx.compose.ui.focus.focusRequester
|
||
import androidx.compose.ui.graphics.Brush
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.geometry.Offset
|
||
import androidx.compose.ui.graphics.Path
|
||
import androidx.compose.ui.graphics.Shadow
|
||
import androidx.compose.ui.input.key.Key
|
||
import androidx.compose.ui.input.key.KeyEventType
|
||
import androidx.compose.ui.input.key.key
|
||
import androidx.compose.ui.input.key.onKeyEvent
|
||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||
import androidx.compose.ui.input.key.type
|
||
import androidx.compose.ui.semantics.contentDescription
|
||
import androidx.compose.ui.semantics.semantics
|
||
import androidx.compose.ui.text.TextStyle
|
||
import androidx.compose.ui.text.font.FontWeight
|
||
import androidx.compose.ui.unit.dp
|
||
import androidx.compose.ui.unit.sp
|
||
import androidx.compose.ui.viewinterop.AndroidView
|
||
import androidx.media3.ui.PlayerView
|
||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||
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
|
||
|
||
/**
|
||
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
|
||
* buttons are ordinary focusable controls; media/channel keys are handled
|
||
* in MainActivity.onKeyDown.
|
||
*/
|
||
@Composable
|
||
fun PlayerScreen(state: AppState) {
|
||
val focusRequester = remember { FocusRequester() }
|
||
|
||
LaunchedEffect(state.overlayVisible) {
|
||
// While the overlay is hidden the root grabs focus so list items
|
||
// underneath stop reacting; the overlay's play button takes over
|
||
// when it appears.
|
||
if (!state.overlayVisible) focusRequester.requestFocus()
|
||
}
|
||
LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible, state.overlayPing) {
|
||
if (state.overlayVisible && state.playerState == "playing") {
|
||
kotlinx.coroutines.delay(5000)
|
||
state.overlayVisible = false
|
||
}
|
||
}
|
||
|
||
Box(
|
||
modifier = Modifier
|
||
.fillMaxSize()
|
||
.background(CastarrColors.bgDeep)
|
||
.focusRequester(focusRequester)
|
||
.focusable()
|
||
.onKeyEvent { false },
|
||
) {
|
||
AndroidView(
|
||
factory = { context ->
|
||
PlayerView(context).apply {
|
||
useController = false
|
||
player = state.player.player
|
||
}
|
||
},
|
||
modifier = Modifier.fillMaxSize(),
|
||
)
|
||
|
||
if (state.overlayVisible) {
|
||
Overlay(state)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Soft drop shadow so overlay text stays readable on bright video. */
|
||
private val OverlayText = TextStyle(
|
||
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
|
||
)
|
||
|
||
private fun formatClock(millis: Long): String =
|
||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||
|
||
@Composable
|
||
private fun Overlay(state: AppState) {
|
||
val playFocus = remember { FocusRequester() }
|
||
LaunchedEffect(Unit) { playFocus.requestFocus() }
|
||
// The key-UP of the very press that opened this overlay arrives after
|
||
// the play button grabbed focus and would click it (instant pause).
|
||
// Swallow select-releases whose press the overlay never saw.
|
||
var sawSelectDown by remember { mutableStateOf(false) }
|
||
|
||
Box(
|
||
Modifier
|
||
.fillMaxSize()
|
||
// Any key while the overlay is open keeps it open a while longer.
|
||
.onPreviewKeyEvent { event ->
|
||
state.pingOverlay()
|
||
val select = event.key == Key.DirectionCenter ||
|
||
event.key == Key.Enter || event.key == Key.NumPadEnter
|
||
when {
|
||
select && event.type == KeyEventType.KeyDown -> {
|
||
sawSelectDown = true
|
||
false
|
||
}
|
||
select && event.type == KeyEventType.KeyUp && !sawSelectDown -> true
|
||
else -> false
|
||
}
|
||
},
|
||
) {
|
||
Box(
|
||
Modifier
|
||
.fillMaxWidth()
|
||
.height(280.dp)
|
||
.align(Alignment.BottomCenter)
|
||
.background(
|
||
Brush.verticalGradient(
|
||
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.94f))
|
||
)
|
||
)
|
||
)
|
||
state.connectedRemote?.let { name ->
|
||
Row(
|
||
modifier = Modifier
|
||
.align(Alignment.TopEnd)
|
||
.padding(top = 22.dp, end = 30.dp),
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
) {
|
||
Text(name, color = CastarrColors.muted, fontFamily = AppFont,
|
||
style = OverlayText, fontSize = 12.sp)
|
||
Spacer(Modifier.width(8.dp))
|
||
Box(
|
||
Modifier
|
||
.size(6.dp)
|
||
.clip(CircleShape)
|
||
.background(CastarrColors.accent)
|
||
)
|
||
}
|
||
}
|
||
Column(
|
||
modifier = Modifier
|
||
.align(Alignment.BottomStart)
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 36.dp)
|
||
.padding(bottom = 24.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…"
|
||
"reconnecting" -> "Verbindung wird wiederhergestellt…"
|
||
"error" -> "Sender gerade nicht erreichbar"
|
||
else -> null
|
||
}
|
||
if (statusWord != null) {
|
||
Row {
|
||
Spacer(Modifier.weight(1f))
|
||
Text(
|
||
statusWord,
|
||
color = CastarrColors.muted,
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 12.sp,
|
||
)
|
||
}
|
||
Spacer(Modifier.height(4.dp))
|
||
}
|
||
Row(verticalAlignment = Alignment.Bottom) {
|
||
// Channel number inline before the name (matches the list
|
||
// and the number-pad zapping).
|
||
val number = state.currentChannel?.let { current ->
|
||
state.activeChannels().indexOfFirst { it.url == current.url } + 1
|
||
} ?: 0
|
||
if (number > 0) {
|
||
Text(
|
||
"$number",
|
||
color = CastarrColors.accent,
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 30.sp,
|
||
fontWeight = FontWeight.SemiBold,
|
||
)
|
||
Spacer(Modifier.width(14.dp))
|
||
}
|
||
Text(
|
||
state.currentChannel?.name.orEmpty(),
|
||
color = CastarrColors.fg,
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 30.sp,
|
||
fontWeight = FontWeight.Medium,
|
||
)
|
||
}
|
||
|
||
val info = state.currentChannel?.let { state.nowNext(it) }
|
||
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||
if (now != null) {
|
||
Spacer(Modifier.height(2.dp))
|
||
Row(verticalAlignment = Alignment.Bottom) {
|
||
Text(
|
||
now.title,
|
||
color = CastarrColors.fg,
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 18.sp,
|
||
)
|
||
Spacer(Modifier.width(14.dp))
|
||
Text(
|
||
"${formatClock(now.start)} – ${formatClock(now.stop)}",
|
||
color = Color(0xFFD7DBE0),
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 14.sp,
|
||
)
|
||
}
|
||
}
|
||
info?.next?.takeUnless {
|
||
isEpgPlaceholder(it.title) || it.title == now?.title
|
||
}?.let { next ->
|
||
Spacer(Modifier.height(2.dp))
|
||
Text(
|
||
"Danach: ${next.title} (${formatClock(next.start)})",
|
||
color = CastarrColors.muted,
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 14.sp,
|
||
)
|
||
}
|
||
|
||
Spacer(Modifier.height(12.dp))
|
||
// Live with EPG: progress of the running programme. Otherwise
|
||
// playback position; unknown duration fills the bar.
|
||
val fraction = when {
|
||
now != null && now.stop > now.start ->
|
||
((System.currentTimeMillis() - now.start).toFloat() / (now.stop - now.start))
|
||
.coerceIn(0f, 1f)
|
||
state.isLive || state.durationMs <= 0 -> 1f
|
||
else -> (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||
}
|
||
Box(
|
||
Modifier
|
||
.fillMaxWidth()
|
||
.height(3.dp)
|
||
.clip(RoundedCornerShape(1.5.dp))
|
||
.background(Color(0x59FFFFFF))
|
||
) {
|
||
Box(
|
||
Modifier
|
||
.fillMaxWidth(fraction)
|
||
.height(3.dp)
|
||
.background(CastarrColors.accent)
|
||
)
|
||
}
|
||
|
||
Spacer(Modifier.height(16.dp))
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
if (state.playerState == "error") {
|
||
// One clear action instead of silently dropping back to
|
||
// the channel list.
|
||
PillButton("Erneut versuchen", focusRequester = playFocus) {
|
||
state.retryPlayback()
|
||
}
|
||
Spacer(Modifier.width(10.dp))
|
||
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
|
||
state.stopPlayback()
|
||
}
|
||
} else {
|
||
IconButton(
|
||
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
|
||
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
|
||
focusRequester = playFocus,
|
||
) { state.player.toggle() }
|
||
Spacer(Modifier.width(10.dp))
|
||
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
|
||
state.stopPlayback()
|
||
}
|
||
}
|
||
if (state.audioTracks.size > 1) {
|
||
Spacer(Modifier.width(10.dp))
|
||
val current = state.audioTracks.firstOrNull { it.selected }
|
||
PillButton(label = "Ton: ${current?.label ?: "–"}") { state.cycleAudio(1) }
|
||
}
|
||
Spacer(Modifier.weight(1f))
|
||
Text(
|
||
"▲ ▼ Sender wechseln",
|
||
color = CastarrColors.faint,
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 12.sp,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum class PillIcon { PLAY, PAUSE, STOP }
|
||
|
||
/** Round icon-only transport button; the icons are drawn, not typed —
|
||
* glyphs like U+23F8 render as emoji on Android. */
|
||
@Composable
|
||
private fun IconButton(
|
||
icon: PillIcon,
|
||
contentDescription: String,
|
||
focusRequester: FocusRequester? = null,
|
||
onClick: () -> Unit,
|
||
) {
|
||
Surface(
|
||
onClick = onClick,
|
||
modifier = (if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||
.semantics { this.contentDescription = contentDescription },
|
||
shape = ClickableSurfaceDefaults.shape(CircleShape),
|
||
colors = ClickableSurfaceDefaults.colors(
|
||
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
|
||
contentColor = CastarrColors.fg,
|
||
focusedContainerColor = CastarrColors.accent,
|
||
focusedContentColor = CastarrColors.onAccent,
|
||
),
|
||
) {
|
||
val color = LocalContentColor.current
|
||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||
Canvas(Modifier.size(15.dp)) {
|
||
when (icon) {
|
||
PillIcon.PLAY -> drawPath(
|
||
Path().apply {
|
||
moveTo(0f, 0f)
|
||
lineTo(size.width, size.height / 2f)
|
||
lineTo(0f, size.height)
|
||
close()
|
||
},
|
||
color,
|
||
)
|
||
PillIcon.PAUSE -> {
|
||
val bar = size.width * 0.3f
|
||
drawRect(color, size = androidx.compose.ui.geometry.Size(bar, size.height))
|
||
drawRect(
|
||
color,
|
||
topLeft = androidx.compose.ui.geometry.Offset(size.width - bar, 0f),
|
||
size = androidx.compose.ui.geometry.Size(bar, size.height),
|
||
)
|
||
}
|
||
PillIcon.STOP -> drawRect(color)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun PillButton(
|
||
label: String,
|
||
focusRequester: FocusRequester? = null,
|
||
onClick: () -> Unit,
|
||
) {
|
||
Surface(
|
||
onClick = onClick,
|
||
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||
colors = ClickableSurfaceDefaults.colors(
|
||
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
|
||
contentColor = CastarrColors.fg,
|
||
focusedContainerColor = CastarrColors.accent,
|
||
focusedContentColor = CastarrColors.onAccent,
|
||
),
|
||
) {
|
||
Text(
|
||
label,
|
||
fontFamily = AppFont,
|
||
style = OverlayText,
|
||
fontSize = 14.sp,
|
||
fontWeight = FontWeight.Medium,
|
||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
|
||
)
|
||
}
|
||
}
|