From a0af187e8f97c7f4ac054714688c656129335681 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 12 Apr 2026 19:02:45 +0200 Subject: [PATCH] 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 --- .../components/TvPlayerControlsOverlayTest.kt | 204 +++++++++++++++++- .../bbara/purefin/tv/player/TvPlayerScreen.kt | 78 ++++++- .../core/player/manager/PlayerManager.kt | 12 ++ .../core/player/viewmodel/PlayerViewModel.kt | 8 + 4 files changed, 299 insertions(+), 3 deletions(-) diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt index 308f6f77..09842dd0 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt @@ -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,11 +44,19 @@ 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 { @@ -346,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 { @@ -500,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(null) } var pendingTrackButtonFocus by remember { mutableStateOf(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() } @@ -516,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 @@ -528,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 } @@ -581,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, diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt index 614e21b1..6df95958 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt @@ -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(null) } var pendingTrackButtonFocus by remember { mutableStateOf(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) + ) + } +} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt index 01ac2e8d..caa6c58e 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt @@ -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 diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index 9ef602cb..7d1b2f92 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -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) }