Files
castarr/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt
be-nj c0adce2b09
Some checks failed
Build TV app / build (push) Failing after 2s
Resolve the full review backlog (issues #1-#13)
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>
2026-08-26 02:14:48 +02:00

404 lines
16 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
)
}
}