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"
|
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
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
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(
|
Text(
|
||||||
"TON",
|
"▲ ▼ Sender wechseln",
|
||||||
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",
|
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
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