diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce60945..0f6fae8 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 = 8 - versionName = "0.4.0" + versionCode = 9 + versionName = "0.4.1" } // 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 8037788..77c75c0 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -42,6 +42,16 @@ class AppState( private set private var lastNowTitle: String? = null + /** Bumped on every interaction with the visible overlay to restart the + * auto-hide timer. */ + var overlayPing by mutableLongStateOf(0L) + private set + + fun pingOverlay() { + overlayVisible = true + overlayPing++ + } + /** Phone-first onboarding progress (ADR-0007). */ var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE) var welcomeUserCode by mutableStateOf("") diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index 501f267..29f7b24 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -200,8 +200,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (state.playerVisible) { when (keyCode) { + // OK opens the control overlay (buttons take over from + // there via normal Compose focus); with the overlay open + // this key is consumed by the focused button and never + // reaches the activity. KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_ENTER -> { + state.pingOverlay() + return true + } KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { state.player.toggle() return true @@ -228,22 +235,24 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { state.zap(1) return true } - // Live streams are not seekable — there left/right cycles the - // audio track instead (overlay shows what happened). + // With the overlay open, left/right moves button focus and + // never reaches the activity. Hidden overlay: seek on + // seekable media, otherwise just bring the controls up. KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS) - else cycleAudioWithOverlay(-1) + else state.pingOverlay() return true } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS) - else cycleAudioWithOverlay(1) + else state.pingOverlay() return true } KeyEvent.KEYCODE_BACK -> { - state.stopPlayback() + if (state.overlayVisible) state.overlayVisible = false + else state.stopPlayback() return true } else -> Unit 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 292bc2f..8c9d831 100644 --- a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt @@ -1,7 +1,6 @@ package dev.castarr.tv.ui import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,11 +25,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent 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.Surface import androidx.tv.material3.Text import dev.castarr.tv.AppState import java.text.SimpleDateFormat @@ -38,16 +40,21 @@ import java.util.Date import java.util.Locale /** - * Fullscreen playback with the minimal auto-hiding overlay. Key handling for - * play/pause/zapping/audio lives in MainActivity.onKeyDown; this composable - * only grabs focus so list items underneath stop reacting. + * 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(Unit) { focusRequester.requestFocus() } - LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible) { + 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 @@ -83,11 +90,22 @@ private fun formatClock(millis: Long): String = @Composable private fun Overlay(state: AppState) { - Box(Modifier.fillMaxSize()) { + val playFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { playFocus.requestFocus() } + + Box( + Modifier + .fillMaxSize() + // Any key while the overlay is open keeps it open a while longer. + .onPreviewKeyEvent { + state.pingOverlay() + false + }, + ) { Box( Modifier .fillMaxWidth() - .height(240.dp) + .height(280.dp) .align(Alignment.BottomCenter) .background( Brush.verticalGradient( @@ -117,7 +135,7 @@ private fun Overlay(state: AppState) { .align(Alignment.BottomStart) .fillMaxWidth() .padding(horizontal = 36.dp) - .padding(bottom = 26.dp), + .padding(bottom = 24.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { if (state.isLive) { @@ -213,58 +231,54 @@ private fun Overlay(state: AppState) { ) } - if (state.audioTracks.size > 1) { - Spacer(Modifier.height(14.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "TON", - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 11.sp, - letterSpacing = 2.sp, - ) - Spacer(Modifier.width(12.dp)) - state.audioTracks.forEach { track -> - Box( - Modifier - .padding(end = 8.dp) - .clip(RoundedCornerShape(999.dp)) - .background( - if (track.selected) CastarrColors.accent else Color.Transparent - ) - .border( - width = 1.dp, - color = if (track.selected) CastarrColors.accent else CastarrColors.line, - shape = RoundedCornerShape(999.dp), - ) - ) { - Text( - track.label, - color = if (track.selected) CastarrColors.onAccent else CastarrColors.muted, - fontFamily = AppFont, - fontSize = 13.sp, - fontWeight = if (track.selected) FontWeight.Medium else FontWeight.Normal, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 5.dp), - ) - } - } + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + PillButton( + label = if (state.playerState == "paused") "▶ Weiter" else "⏸ Pause", + focusRequester = playFocus, + ) { state.player.toggle() } + Spacer(Modifier.width(10.dp)) + PillButton(label = "■ 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, + fontSize = 12.sp, + ) } - - Spacer(Modifier.height(12.dp)) - // Key hints instead of on-screen buttons — nothing to focus, - // nothing to get stuck on (#14 spirit). - val trickplay = when { - state.audioTracks.size > 1 -> " · ◀ ▶ Tonspur" - state.player.player.isCurrentMediaItemSeekable -> " · ◀ ▶ Spulen" - else -> "" - } - Text( - "OK Pause/Weiter · ▲ ▼ Sender$trickplay · Zurück Beenden", - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 12.sp, - ) } } } + +@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, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp), + ) + } +}