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:
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user