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:
be-nj
2026-08-26 00:05:25 +02:00
parent b12bfdf6e8
commit 00762b6cbb
4 changed files with 100 additions and 67 deletions

View File

@@ -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

View File

@@ -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("")

View File

@@ -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

View File

@@ -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,54 +231,22 @@ private fun Overlay(state: AppState) {
)
}
if (state.audioTracks.size > 1) {
Spacer(Modifier.height(14.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(
"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(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",
"▲ ▼ Sender wechseln",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
@@ -268,3 +254,31 @@ private fun Overlay(state: AppState) {
}
}
}
}
@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),
)
}
}