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), ) } }