Player overlay: real transport buttons (pause/stop/audio), OK opens controls

OK now opens the overlay like every TV player; its buttons are ordinary
focusable pills (play/pause, stop, audio-track cycle). Back closes the
overlay first, then stops. Auto-hide timer restarts on any interaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 00:05:25 +02:00
parent 67639660bc
commit bcbe581534
4 changed files with 100 additions and 67 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 8 versionCode = 9
versionName = "0.4.0" versionName = "0.4.1"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -42,6 +42,16 @@ class AppState(
private set private set
private var lastNowTitle: String? = null 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). */ /** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE) var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
var welcomeUserCode by mutableStateOf("") var welcomeUserCode by mutableStateOf("")

View File

@@ -200,8 +200,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (state.playerVisible) { if (state.playerVisible) {
when (keyCode) { 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_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_ENTER -> {
state.pingOverlay()
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
state.player.toggle() state.player.toggle()
return true return true
@@ -228,22 +235,24 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.zap(1) state.zap(1)
return true return true
} }
// Live streams are not seekable — there left/right cycles the // With the overlay open, left/right moves button focus and
// audio track instead (overlay shows what happened). // never reaches the activity. Hidden overlay: seek on
// seekable media, otherwise just bring the controls up.
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> { KeyEvent.KEYCODE_MEDIA_REWIND -> {
if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
else cycleAudioWithOverlay(-1) else state.pingOverlay()
return true return true
} }
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS)
else cycleAudioWithOverlay(1) else state.pingOverlay()
return true return true
} }
KeyEvent.KEYCODE_BACK -> { KeyEvent.KEYCODE_BACK -> {
state.stopPlayback() if (state.overlayVisible) state.overlayVisible = false
else state.stopPlayback()
return true return true
} }
else -> Unit else -> Unit

View File

@@ -1,7 +1,6 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -38,16 +40,21 @@ import java.util.Date
import java.util.Locale import java.util.Locale
/** /**
* Fullscreen playback with the minimal auto-hiding overlay. Key handling for * Fullscreen playback. OK opens the auto-hiding overlay whose transport
* play/pause/zapping/audio lives in MainActivity.onKeyDown; this composable * buttons are ordinary focusable controls; media/channel keys are handled
* only grabs focus so list items underneath stop reacting. * in MainActivity.onKeyDown.
*/ */
@Composable @Composable
fun PlayerScreen(state: AppState) { fun PlayerScreen(state: AppState) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() } LaunchedEffect(state.overlayVisible) {
LaunchedEffect(state.playerVisible, state.playerState, 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") { if (state.overlayVisible && state.playerState == "playing") {
kotlinx.coroutines.delay(5000) kotlinx.coroutines.delay(5000)
state.overlayVisible = false state.overlayVisible = false
@@ -83,11 +90,22 @@ private fun formatClock(millis: Long): String =
@Composable @Composable
private fun Overlay(state: AppState) { 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( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(240.dp) .height(280.dp)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
@@ -117,7 +135,7 @@ private fun Overlay(state: AppState) {
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 36.dp) .padding(horizontal = 36.dp)
.padding(bottom = 26.dp), .padding(bottom = 24.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (state.isLive) { if (state.isLive) {
@@ -213,58 +231,54 @@ private fun Overlay(state: AppState) {
) )
} }
if (state.audioTracks.size > 1) { Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(14.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { PillButton(
Text( label = if (state.playerState == "paused") "▶ Weiter" else "⏸ Pause",
"TON", focusRequester = playFocus,
color = CastarrColors.faint, ) { state.player.toggle() }
fontFamily = AppFont, Spacer(Modifier.width(10.dp))
fontSize = 11.sp, PillButton(label = "■ Beenden") { state.stopPlayback() }
letterSpacing = 2.sp, if (state.audioTracks.size > 1) {
) Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(12.dp)) val current = state.audioTracks.firstOrNull { it.selected }
state.audioTracks.forEach { track -> PillButton(label = "Ton: ${current?.label ?: ""}") { state.cycleAudio(1) }
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.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),
)
}
}