Compare commits

...

2 Commits

Author SHA1 Message Date
a0af187e8f feat: implement quick play/pause toggle for TV player
Adds the ability to play and pause media using the Center or Enter keys
without displaying the full player controls. When pausing via this
method, a brief visual "Pause" feedback overlay is shown in the center
of the screen.

- Added `TvPlayerHiddenStopFeedback` component and related logic
- Updated `handleTvPlayerRootKeyEvent` to support background playback toggling
- Added `pausePlayback` and `resumePlayback` to `PlayerViewModel` and `PlayerManager`
- Included comprehensive UI tests for the new hidden control behavior
2026-04-12 19:02:45 +02:00
16839942b1 Focus play/pause button when player controls open 2026-04-12 18:39:57 +02:00
5 changed files with 333 additions and 9 deletions

View File

@@ -1,6 +1,8 @@
package hu.bbara.purefin.tv.player.components
import androidx.activity.ComponentActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
@@ -10,12 +12,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
@@ -37,17 +44,48 @@ import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.tv.player.TV_HIDDEN_STOP_FEEDBACK_MS
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedback
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedbackTag
import hu.bbara.purefin.tv.player.handleTvPlayerRootKeyEvent
import hu.bbara.purefin.ui.theme.AppTheme
import kotlinx.coroutines.delay
import org.junit.Rule
import org.junit.Test
private const val TvPlayerRootTag = "tv_player_root"
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
@OptIn(ExperimentalTestApi::class)
class TvPlayerControlsOverlayTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState(),
requestFocus = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.Focused,
false
)
)
}
@Test
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
composeRule.setContent {
@@ -323,6 +361,137 @@ class TvPlayerControlsOverlayTest {
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
}
@Test
fun hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.Enter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() {
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.mainClock.advanceTimeByFrame()
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1)
composeRule.mainClock.advanceTimeByFrame()
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.mainClock.advanceTimeBy(1)
composeRule.mainClock.advanceTimeBy(300)
composeRule.mainClock.advanceTimeByFrame()
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.mainClock.autoAdvance = true
}
@Test
fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState().copy(isPlaying = false),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"0"
)
)
composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
composeRule.setContent {
@@ -433,7 +602,8 @@ class TvPlayerControlsOverlayTest {
@Composable
private fun OverlayHost(
uiState: PlayerUiState,
initiallyExpanded: Boolean = false
initiallyExpanded: Boolean = false,
requestFocus: Boolean = false
) {
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
val focusRequester = remember { FocusRequester() }
@@ -441,6 +611,12 @@ private fun OverlayHost(
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
}
}
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay(
uiState = uiState,
@@ -470,11 +646,17 @@ private fun OverlayHost(
@Composable
private fun TrackPanelHost(
uiState: PlayerUiState
uiState: PlayerUiState,
initialControlsVisible: Boolean = true
) {
var controlsVisible by remember { mutableStateOf(true) }
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) }
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
val rootFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
@@ -486,6 +668,15 @@ private fun TrackPanelHost(
controlsVisible = true
}
}
val pausePlaybackWithoutShowingControls: () -> Unit = {
pauseWithoutControlsCount += 1
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
resumeWithoutControlsCount += 1
stopFeedbackVisible = false
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
@@ -498,18 +689,38 @@ private fun TrackPanelHost(
pendingTrackButtonFocus = null
}
LaunchedEffect(stopFeedbackRequestId) {
if (stopFeedbackRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
stopFeedbackVisible = false
}
LaunchedEffect(controlsVisible, trackPanelType) {
if (controlsVisible || trackPanelType != null) {
stopFeedbackVisible = false
} else {
rootFocusRequester.requestFocus()
}
}
Box(
modifier = Modifier
.size(width = 960.dp, height = 540.dp)
.focusRequester(rootFocusRequester)
.focusable()
.testTag(TvPlayerRootTag)
.onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent(
event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible,
isPlaylistExpanded = false,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = { controlsVisible = true },
onHideControls = { controlsVisible = false },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = { _ -> controlsVisible = true },
onShowControls = { controlsVisible = true },
onTogglePlayPause = { controlsVisible = true }
@@ -551,6 +762,25 @@ private fun TrackPanelHost(
)
}
AnimatedVisibility(
visible = stopFeedbackVisible,
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerHiddenStopFeedback()
}
Box(
modifier = Modifier
.testTag(TvPlayerHiddenPauseCountTag)
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
)
Box(
modifier = Modifier
.testTag(TvPlayerHiddenResumeCountTag)
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
)
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,

View File

@@ -14,6 +14,13 @@ import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -24,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
@@ -33,6 +41,8 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
@@ -44,8 +54,11 @@ import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.tv.player.components.TvTrackPanelType
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.delay
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
@OptIn(UnstableApi::class)
@Composable
@@ -63,6 +76,8 @@ fun TvPlayerScreen(
var isPlaylistExpanded by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) }
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
val context = LocalContext.current
@@ -118,6 +133,15 @@ fun TvPlayerScreen(
val togglePlayPauseAndShowControls: () -> Unit = {
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
}
val pausePlaybackWithoutShowingControls: () -> Unit = {
viewModel.pausePlayback()
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
viewModel.resumePlayback()
stopFeedbackVisible = false
}
val seekAndShowControls: (Long) -> Unit = { positionMs ->
viewModel.seekTo(positionMs)
showTvControls()
@@ -164,6 +188,18 @@ fun TvPlayerScreen(
pendingTrackButtonFocus = null
}
LaunchedEffect(stopFeedbackRequestId) {
if (stopFeedbackRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
stopFeedbackVisible = false
}
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
stopFeedbackVisible = false
}
}
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
@@ -181,12 +217,15 @@ fun TvPlayerScreen(
.onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent(
event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible,
isPlaylistExpanded = isPlaylistExpanded,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = collapsePlaylistToControls,
onHideControls = { viewModel.toggleControlsVisibility() },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = seekByAndShowControls,
onShowControls = showTvControls,
onTogglePlayPause = togglePlayPauseAndShowControls
@@ -254,6 +293,15 @@ fun TvPlayerScreen(
onDismissError = { viewModel.clearError() }
)
AnimatedVisibility(
visible = stopFeedbackVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerHiddenStopFeedback()
}
AnimatedVisibility(
visible = trackPanelType != null,
enter = slideInHorizontally { it },
@@ -277,12 +325,15 @@ fun TvPlayerScreen(
internal fun handleTvPlayerRootKeyEvent(
event: androidx.compose.ui.input.key.KeyEvent,
isPlaying: Boolean,
controlsVisible: Boolean,
isPlaylistExpanded: Boolean,
trackPanelType: TvTrackPanelType?,
onCloseTrackPanel: () -> Unit,
onCollapsePlaylist: () -> Unit,
onHideControls: () -> Unit,
onPausePlaybackWithoutShowingControls: () -> Unit,
onResumePlaybackWithoutShowingControls: () -> Unit,
onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit,
onTogglePlayPause: () -> Unit
@@ -328,7 +379,11 @@ internal fun handleTvPlayerRootKeyEvent(
}
Key.DirectionCenter, Key.Enter -> {
onTogglePlayPause()
if (isPlaying) {
onPausePlaybackWithoutShowingControls()
} else {
onResumePlaybackWithoutShowingControls()
}
true
}
@@ -338,3 +393,24 @@ internal fun handleTvPlayerRootKeyEvent(
return false
}
@Composable
internal fun TvPlayerHiddenStopFeedback(
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.testTag(TvPlayerHiddenStopFeedbackTag)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.72f))
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Pause,
contentDescription = "Pause playback",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(72.dp)
)
}
}

View File

@@ -186,7 +186,6 @@ private fun TvPlayerBottomSection(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val playPauseFocusRequester = remember { FocusRequester() }
val playlistFocusRequester = remember { FocusRequester() }
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
@@ -194,7 +193,7 @@ private fun TvPlayerBottomSection(
if (isPlaylistExpanded && uiState.queue.isNotEmpty()) {
playlistFocusRequester.requestFocus()
} else if (wasPlaylistExpanded) {
playPauseFocusRequester.requestFocus()
focusRequester.requestFocus()
}
wasPlaylistExpanded = isPlaylistExpanded
}
@@ -255,10 +254,9 @@ private fun TvPlayerBottomSection(
onSeekRelative = onSeekRelative,
togglePlayState = onPlayPause,
onMoveDown = {
playPauseFocusRequester.requestFocus()
focusRequester.requestFocus()
true
},
focusRequester = focusRequester,
modifier = Modifier.testTag(TvPlayerSeekBarTag)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -292,7 +290,7 @@ private fun TvPlayerBottomSection(
onClick = onPlayPause,
size = 72,
modifier = expandPlaylistModifier
.focusRequester(playPauseFocusRequester)
.focusRequester(focusRequester)
.testTag(TvPlayerPlayPauseButtonTag)
)
TvIconButton(

View File

@@ -139,6 +139,18 @@ class PlayerManager @Inject constructor(
if (player.isPlaying) player.pause() else player.play()
}
fun pausePlayback() {
if (player.isPlaying) {
player.pause()
}
}
fun resumePlayback() {
if (!player.isPlaying) {
player.play()
}
}
fun seekTo(positionMs: Long) {
val target = clampSeekPosition(positionMs)
pendingSeekPositionMs = target

View File

@@ -188,6 +188,14 @@ class PlayerViewModel @Inject constructor(
showControls(autoHideDelayMs)
}
fun pausePlayback() {
playerManager.pausePlayback()
}
fun resumePlayback() {
playerManager.resumePlayback()
}
fun seekTo(positionMs: Long) {
playerManager.seekTo(positionMs)
}