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 package hu.bbara.purefin.tv.player.components
import androidx.activity.ComponentActivity 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.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -10,12 +12,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.Key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties 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.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert 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.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType 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.tv.player.handleTvPlayerRootKeyEvent
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import kotlinx.coroutines.delay
import org.junit.Rule import org.junit.Rule
import org.junit.Test 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) @OptIn(ExperimentalTestApi::class)
class TvPlayerControlsOverlayTest { class TvPlayerControlsOverlayTest {
@get:Rule @get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>() 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 @Test
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() { fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
composeRule.setContent { composeRule.setContent {
@@ -323,6 +361,137 @@ class TvPlayerControlsOverlayTest {
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0) 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 @Test
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() { fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
composeRule.setContent { composeRule.setContent {
@@ -433,7 +602,8 @@ class TvPlayerControlsOverlayTest {
@Composable @Composable
private fun OverlayHost( private fun OverlayHost(
uiState: PlayerUiState, uiState: PlayerUiState,
initiallyExpanded: Boolean = false initiallyExpanded: Boolean = false,
requestFocus: Boolean = false
) { ) {
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) } var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@@ -441,6 +611,12 @@ private fun OverlayHost(
val audioButtonFocusRequester = remember { FocusRequester() } val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() } val subtitlesButtonFocusRequester = remember { FocusRequester() }
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
}
}
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) { Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay( TvPlayerControlsOverlay(
uiState = uiState, uiState = uiState,
@@ -470,11 +646,17 @@ private fun OverlayHost(
@Composable @Composable
private fun TrackPanelHost( 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 trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus 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 controlsFocusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() } val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() } val audioButtonFocusRequester = remember { FocusRequester() }
@@ -486,6 +668,15 @@ private fun TrackPanelHost(
controlsVisible = true controlsVisible = true
} }
} }
val pausePlaybackWithoutShowingControls: () -> Unit = {
pauseWithoutControlsCount += 1
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
resumeWithoutControlsCount += 1
stopFeedbackVisible = false
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) { LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
@@ -498,18 +689,38 @@ private fun TrackPanelHost(
pendingTrackButtonFocus = null 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( Box(
modifier = Modifier modifier = Modifier
.size(width = 960.dp, height = 540.dp) .size(width = 960.dp, height = 540.dp)
.focusRequester(rootFocusRequester)
.focusable()
.testTag(TvPlayerRootTag)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent( handleTvPlayerRootKeyEvent(
event = event, event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible, controlsVisible = controlsVisible,
isPlaylistExpanded = false, isPlaylistExpanded = false,
trackPanelType = trackPanelType, trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel, onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = { controlsVisible = true }, onCollapsePlaylist = { controlsVisible = true },
onHideControls = { controlsVisible = false }, onHideControls = { controlsVisible = false },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = { _ -> controlsVisible = true }, onSeekRelative = { _ -> controlsVisible = true },
onShowControls = { controlsVisible = true }, onShowControls = { controlsVisible = true },
onTogglePlayPause = { 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 -> trackPanelType?.let { panelType ->
TvTrackSelectionPanel( TvTrackSelectionPanel(
panelType = panelType, panelType = panelType,

View File

@@ -14,6 +14,13 @@ import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -24,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color 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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext 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.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi 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.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.tv.player.components.TvTrackPanelType import hu.bbara.purefin.tv.player.components.TvTrackPanelType
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.delay
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L 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) @OptIn(UnstableApi::class)
@Composable @Composable
@@ -63,6 +76,8 @@ fun TvPlayerScreen(
var isPlaylistExpanded by remember { mutableStateOf(false) } var isPlaylistExpanded by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) } var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus 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 controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
val context = LocalContext.current val context = LocalContext.current
@@ -118,6 +133,15 @@ fun TvPlayerScreen(
val togglePlayPauseAndShowControls: () -> Unit = { val togglePlayPauseAndShowControls: () -> Unit = {
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS) 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 -> val seekAndShowControls: (Long) -> Unit = { positionMs ->
viewModel.seekTo(positionMs) viewModel.seekTo(positionMs)
showTvControls() showTvControls()
@@ -164,6 +188,18 @@ fun TvPlayerScreen(
pendingTrackButtonFocus = null 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) { BackHandler(enabled = true) {
when { when {
trackPanelType != null -> closeTrackPanel() trackPanelType != null -> closeTrackPanel()
@@ -181,12 +217,15 @@ fun TvPlayerScreen(
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent( handleTvPlayerRootKeyEvent(
event = event, event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible, controlsVisible = controlsVisible,
isPlaylistExpanded = isPlaylistExpanded, isPlaylistExpanded = isPlaylistExpanded,
trackPanelType = trackPanelType, trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel, onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = collapsePlaylistToControls, onCollapsePlaylist = collapsePlaylistToControls,
onHideControls = { viewModel.toggleControlsVisibility() }, onHideControls = { viewModel.toggleControlsVisibility() },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = seekByAndShowControls, onSeekRelative = seekByAndShowControls,
onShowControls = showTvControls, onShowControls = showTvControls,
onTogglePlayPause = togglePlayPauseAndShowControls onTogglePlayPause = togglePlayPauseAndShowControls
@@ -254,6 +293,15 @@ fun TvPlayerScreen(
onDismissError = { viewModel.clearError() } onDismissError = { viewModel.clearError() }
) )
AnimatedVisibility(
visible = stopFeedbackVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerHiddenStopFeedback()
}
AnimatedVisibility( AnimatedVisibility(
visible = trackPanelType != null, visible = trackPanelType != null,
enter = slideInHorizontally { it }, enter = slideInHorizontally { it },
@@ -277,12 +325,15 @@ fun TvPlayerScreen(
internal fun handleTvPlayerRootKeyEvent( internal fun handleTvPlayerRootKeyEvent(
event: androidx.compose.ui.input.key.KeyEvent, event: androidx.compose.ui.input.key.KeyEvent,
isPlaying: Boolean,
controlsVisible: Boolean, controlsVisible: Boolean,
isPlaylistExpanded: Boolean, isPlaylistExpanded: Boolean,
trackPanelType: TvTrackPanelType?, trackPanelType: TvTrackPanelType?,
onCloseTrackPanel: () -> Unit, onCloseTrackPanel: () -> Unit,
onCollapsePlaylist: () -> Unit, onCollapsePlaylist: () -> Unit,
onHideControls: () -> Unit, onHideControls: () -> Unit,
onPausePlaybackWithoutShowingControls: () -> Unit,
onResumePlaybackWithoutShowingControls: () -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit, onShowControls: () -> Unit,
onTogglePlayPause: () -> Unit onTogglePlayPause: () -> Unit
@@ -328,7 +379,11 @@ internal fun handleTvPlayerRootKeyEvent(
} }
Key.DirectionCenter, Key.Enter -> { Key.DirectionCenter, Key.Enter -> {
onTogglePlayPause() if (isPlaying) {
onPausePlaybackWithoutShowingControls()
} else {
onResumePlaybackWithoutShowingControls()
}
true true
} }
@@ -338,3 +393,24 @@ internal fun handleTvPlayerRootKeyEvent(
return false 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 modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val playPauseFocusRequester = remember { FocusRequester() }
val playlistFocusRequester = remember { FocusRequester() } val playlistFocusRequester = remember { FocusRequester() }
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) } var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
@@ -194,7 +193,7 @@ private fun TvPlayerBottomSection(
if (isPlaylistExpanded && uiState.queue.isNotEmpty()) { if (isPlaylistExpanded && uiState.queue.isNotEmpty()) {
playlistFocusRequester.requestFocus() playlistFocusRequester.requestFocus()
} else if (wasPlaylistExpanded) { } else if (wasPlaylistExpanded) {
playPauseFocusRequester.requestFocus() focusRequester.requestFocus()
} }
wasPlaylistExpanded = isPlaylistExpanded wasPlaylistExpanded = isPlaylistExpanded
} }
@@ -255,10 +254,9 @@ private fun TvPlayerBottomSection(
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
togglePlayState = onPlayPause, togglePlayState = onPlayPause,
onMoveDown = { onMoveDown = {
playPauseFocusRequester.requestFocus() focusRequester.requestFocus()
true true
}, },
focusRequester = focusRequester,
modifier = Modifier.testTag(TvPlayerSeekBarTag) modifier = Modifier.testTag(TvPlayerSeekBarTag)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -292,7 +290,7 @@ private fun TvPlayerBottomSection(
onClick = onPlayPause, onClick = onPlayPause,
size = 72, size = 72,
modifier = expandPlaylistModifier modifier = expandPlaylistModifier
.focusRequester(playPauseFocusRequester) .focusRequester(focusRequester)
.testTag(TvPlayerPlayPauseButtonTag) .testTag(TvPlayerPlayPauseButtonTag)
) )
TvIconButton( TvIconButton(

View File

@@ -139,6 +139,18 @@ class PlayerManager @Inject constructor(
if (player.isPlaying) player.pause() else player.play() 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) { fun seekTo(positionMs: Long) {
val target = clampSeekPosition(positionMs) val target = clampSeekPosition(positionMs)
pendingSeekPositionMs = target pendingSeekPositionMs = target

View File

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