mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
refactor(player): move controls auto-hide from ViewModel to composable layer
This commit is contained in:
@@ -1,979 +0,0 @@
|
||||
package hu.bbara.purefin.ui.screen.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
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag
|
||||
import hu.bbara.purefin.ui.screen.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"
|
||||
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
|
||||
|
||||
@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 {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"collapsed"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"expanded"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionUp)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"collapsed"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rightOnLastPlaylistItem_keepsFocusInPlaylist() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"expanded"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState(),
|
||||
initiallyExpanded = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Current").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Already Played").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState().copy(
|
||||
queue = listOf(
|
||||
PlaylistElementUiModel(
|
||||
id = "first",
|
||||
title = "Episode 1",
|
||||
subtitle = "Fallback entry",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "second",
|
||||
title = "Episode 2",
|
||||
subtitle = "Later",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
)
|
||||
)
|
||||
),
|
||||
initiallyExpanded = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Episode 1").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingTrackPanel_focusesSelectedTrackAndHasNoCloseButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
||||
composeRule.onAllNodesWithContentDescription("Close").assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingTrackPanelWithoutSelection_focusesFirstTrack() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(selectedAudioTrackId = null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backOnVisibleControls_hidesOverlayImmediately() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Back)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapeOnVisibleControls_hidesOverlayImmediately() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Escape)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).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
|
||||
fun hiddenControls_leftSeeksWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionLeft)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"-10000"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_rightSeeksWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"10000"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Back)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leftOnTrackPanel_keepsFocusInsidePanel() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionLeft)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackButtonsWithoutOptions_areDisabled() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(
|
||||
audioTracks = emptyList(),
|
||||
textTracks = emptyList(),
|
||||
qualityTracks = emptyList(),
|
||||
selectedAudioTrackId = null,
|
||||
selectedTextTrackId = null,
|
||||
selectedQualityTrackId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled()
|
||||
composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled()
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayHost(
|
||||
uiState: PlayerUiState,
|
||||
initiallyExpanded: Boolean = false,
|
||||
requestFocus: Boolean = false
|
||||
) {
|
||||
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
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,
|
||||
focusRequester = focusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = {},
|
||||
onSeek = { _ -> },
|
||||
onSeekRelative = { _ -> },
|
||||
onSeekLiveEdge = {},
|
||||
onSkipSegment = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onOpenAudioPanel = {},
|
||||
onOpenSubtitlesPanel = {},
|
||||
onOpenQualityPanel = {},
|
||||
onExpandPlaylist = { isPlaylistExpanded = true },
|
||||
onCollapsePlaylist = { isPlaylistExpanded = false },
|
||||
onSelectQueueItem = { _ -> },
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackPanelHost(
|
||||
uiState: PlayerUiState,
|
||||
initialControlsVisible: Boolean = 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) }
|
||||
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
|
||||
val rootFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val closeTrackPanel: () -> Unit = {
|
||||
trackPanelType?.let { panelType ->
|
||||
pendingTrackButtonFocus = panelType
|
||||
trackPanelType = null
|
||||
controlsVisible = true
|
||||
}
|
||||
}
|
||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
||||
pauseWithoutControlsCount += 1
|
||||
stopFeedbackVisible = true
|
||||
stopFeedbackRequestId += 1
|
||||
}
|
||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
||||
resumeWithoutControlsCount += 1
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
lastHiddenSeekDeltaMs = deltaMs
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
if (trackPanelType != null) return@LaunchedEffect
|
||||
when (pendingFocus) {
|
||||
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
|
||||
}
|
||||
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 = seekWithoutShowingControls,
|
||||
onShowControls = { controlsVisible = true },
|
||||
onTogglePlayPause = { controlsVisible = true }
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (controlsVisible || trackPanelType != null) {
|
||||
TvPlayerControlsOverlay(
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
isPlaylistExpanded = false,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = {},
|
||||
onSeek = { _ -> },
|
||||
onSeekRelative = { _ -> },
|
||||
onSeekLiveEdge = {},
|
||||
onSkipSegment = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onOpenAudioPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.AUDIO
|
||||
},
|
||||
onOpenSubtitlesPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.SUBTITLES
|
||||
},
|
||||
onOpenQualityPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.QUALITY
|
||||
},
|
||||
onExpandPlaylist = {},
|
||||
onCollapsePlaylist = {},
|
||||
onSelectQueueItem = { _ -> },
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
|
||||
)
|
||||
|
||||
trackPanelType?.let { panelType ->
|
||||
TvTrackSelectionPanel(
|
||||
panelType = panelType,
|
||||
uiState = uiState,
|
||||
onSelect = { closeTrackPanel() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
|
||||
isPlaying = true,
|
||||
title = "Sample Player",
|
||||
subtitle = "Season 1",
|
||||
durationMs = 3_600_000,
|
||||
positionMs = 120_000,
|
||||
bufferedMs = 240_000,
|
||||
queue = listOf(
|
||||
PlaylistElementUiModel(
|
||||
id = "played",
|
||||
title = "Already Played",
|
||||
subtitle = "Episode 0",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "current",
|
||||
title = "Currently Playing",
|
||||
subtitle = "Episode 1",
|
||||
artworkUrl = null,
|
||||
isCurrent = true
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "next",
|
||||
title = "Episode 2",
|
||||
subtitle = "Up next",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
)
|
||||
),
|
||||
audioTracks = listOf(
|
||||
TrackOption(
|
||||
id = "audio_en",
|
||||
label = "English 5.1",
|
||||
language = "en",
|
||||
bitrate = null,
|
||||
channelCount = 6,
|
||||
height = null,
|
||||
groupIndex = 0,
|
||||
trackIndex = 0,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
),
|
||||
TrackOption(
|
||||
id = "audio_hu",
|
||||
label = "Hungarian Stereo",
|
||||
language = "hu",
|
||||
bitrate = null,
|
||||
channelCount = 2,
|
||||
height = null,
|
||||
groupIndex = 0,
|
||||
trackIndex = 1,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
textTracks = listOf(
|
||||
TrackOption(
|
||||
id = "text_off",
|
||||
label = "Off",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = 1,
|
||||
trackIndex = -1,
|
||||
type = TrackType.TEXT,
|
||||
isOff = true
|
||||
),
|
||||
TrackOption(
|
||||
id = "text_en",
|
||||
label = "English CC",
|
||||
language = "en",
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = 1,
|
||||
trackIndex = 0,
|
||||
type = TrackType.TEXT,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
qualityTracks = listOf(
|
||||
TrackOption(
|
||||
id = "quality_auto",
|
||||
label = "Auto",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = 1080,
|
||||
groupIndex = 2,
|
||||
trackIndex = 0,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
),
|
||||
TrackOption(
|
||||
id = "quality_720",
|
||||
label = "720p",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = 720,
|
||||
groupIndex = 2,
|
||||
trackIndex = 1,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
selectedAudioTrackId = "audio_hu",
|
||||
selectedTextTrackId = "text_en",
|
||||
selectedQualityTrackId = "quality_auto"
|
||||
)
|
||||
@@ -30,9 +30,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -41,34 +42,36 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
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.LocalFocusManager
|
||||
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.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvIconButton
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
|
||||
@@ -86,21 +89,28 @@ fun TvPlayerScreen(
|
||||
viewModel.loadMedia(mediaId)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
// TODO why this is needed???
|
||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||
var stopFeedbackRequestId by remember { mutableIntStateOf(0) }
|
||||
var hiddenSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
var hiddenSeekRequestId by remember { mutableStateOf(0) }
|
||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
||||
var hiddenSeekRequestId by remember { mutableIntStateOf(0) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val rootFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val backgroundFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// Main section focus requesters
|
||||
var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) }
|
||||
var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) }
|
||||
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
@@ -110,7 +120,11 @@ fun TvPlayerScreen(
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
viewModel.pausePlayback()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
val activity = context as? Activity
|
||||
if (uiState.isPlaying) {
|
||||
@@ -119,118 +133,73 @@ fun TvPlayerScreen(
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
|
||||
|
||||
fun showControls() {
|
||||
controlsVisible = true
|
||||
}
|
||||
fun hideControls() {
|
||||
controlsVisible = false
|
||||
}
|
||||
fun hideControlsWithTimeout() {
|
||||
hideControlsJob?.cancel()
|
||||
if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) {
|
||||
hideControlsJob = scope.launch {
|
||||
delay(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Needed because of composition scheduling
|
||||
LaunchedEffect(controlsVisible) {
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPlaylistExpanded) {
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType) {
|
||||
viewModel.setControlsAutoHideBlocked(
|
||||
ControlsAutoHideBlocker.TRACK_PANEL,
|
||||
trackPanelType != null
|
||||
)
|
||||
}
|
||||
|
||||
val expandPlaylist: () -> Unit = {
|
||||
if (!isPlaylistExpanded) {
|
||||
fun expandPlaylist() {
|
||||
// TODO: focus management for playlist expansion
|
||||
isPlaylistExpanded = true
|
||||
}
|
||||
}
|
||||
val collapsePlaylistToControls: () -> Unit = {
|
||||
if (isPlaylistExpanded) {
|
||||
fun closePlaylist() {
|
||||
// TODO focus
|
||||
isPlaylistExpanded = false
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
val showTvControls: () -> Unit = {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
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()
|
||||
}
|
||||
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs
|
||||
hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs)
|
||||
hiddenSeekRequestId += 1
|
||||
viewModel.seekBy(deltaMs)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekToLiveEdgeAndShowControls: () -> Unit = {
|
||||
viewModel.seekToLiveEdge()
|
||||
showTvControls()
|
||||
}
|
||||
val skipSegmentAndShowControls: () -> Unit = {
|
||||
viewModel.skipActiveSegment()
|
||||
showTvControls()
|
||||
}
|
||||
val nextAndShowControls: () -> Unit = {
|
||||
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val previousAndShowControls: () -> Unit = {
|
||||
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val closeTrackPanel: () -> Unit = {
|
||||
trackPanelType?.let { panelType ->
|
||||
pendingTrackButtonFocus = panelType
|
||||
trackPanelType = null
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val playerControlsVisible =
|
||||
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
|
||||
|
||||
val showNextEpisodeOverlay = !playerControlsVisible
|
||||
val showSkipIntroButton = !controlsVisible
|
||||
&& uiState.activeSkippableSegmentEndMs != null
|
||||
&& !uiState.isEnded
|
||||
val showNextEpisodeOverlay = !controlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
||||
&& !uiState.isEnded
|
||||
val showHiddenSeekPreview = !controlsVisible && hiddenSeekPreviewPositionMs != null
|
||||
LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
|
||||
rootFocusRequester = when {
|
||||
showSkipIntroButton -> {
|
||||
skipButtonFocusRequester
|
||||
}
|
||||
showNextEpisodeOverlay -> {
|
||||
nextEpisodeFocusRequester
|
||||
}
|
||||
else -> {
|
||||
backgroundFocusRequester
|
||||
}
|
||||
}
|
||||
if (!controlsVisible) {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
controlsVisible,
|
||||
controlsAutoHideBlocked,
|
||||
uiState.activeSkippableSegmentEndMs,
|
||||
showNextEpisodeOverlay
|
||||
) {
|
||||
if (controlsAutoHideBlocked) return@LaunchedEffect
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||
skipButtonFocusRequester.requestFocus()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (showNextEpisodeOverlay) {
|
||||
nextEpisodeFocusRequester.requestFocus()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
@@ -243,6 +212,7 @@ fun TvPlayerScreen(
|
||||
pendingTrackButtonFocus = null
|
||||
}
|
||||
|
||||
// TODO: use animated visibility for this type of feedback that is already implemented
|
||||
LaunchedEffect(stopFeedbackRequestId) {
|
||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||
@@ -263,7 +233,7 @@ fun TvPlayerScreen(
|
||||
}
|
||||
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||
@@ -272,8 +242,12 @@ fun TvPlayerScreen(
|
||||
BackHandler(enabled = true) {
|
||||
when {
|
||||
trackPanelType != null -> closeTrackPanel()
|
||||
isPlaylistExpanded -> collapsePlaylistToControls()
|
||||
controlsVisible -> viewModel.toggleControlsVisibility()
|
||||
isPlaylistExpanded -> {
|
||||
closePlaylist()
|
||||
}
|
||||
controlsVisible -> {
|
||||
hideControls()
|
||||
}
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
@@ -284,23 +258,28 @@ fun TvPlayerScreen(
|
||||
.background(Color.Black)
|
||||
.focusRequester(rootFocusRequester)
|
||||
.onPreviewKeyEvent { event ->
|
||||
handleTvPlayerRootKeyEvent(
|
||||
val handled = handleTvPlayerRootKeyEvent(
|
||||
event = event,
|
||||
isPlaying = uiState.isPlaying,
|
||||
controlsVisible = controlsVisible,
|
||||
onShowControls = ::showControls,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
trackPanelType = trackPanelType,
|
||||
onCloseTrackPanel = closeTrackPanel,
|
||||
onCollapsePlaylist = collapsePlaylistToControls,
|
||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||
onSeekRelative = seekByWithoutShowingControls,
|
||||
onShowControls = showTvControls,
|
||||
onSkipSegment = skipSegmentAndShowControls,
|
||||
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null,
|
||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
||||
onCollapsePlaylist = {},
|
||||
onHideControls = ::hideControls,
|
||||
onPausePlayback = { viewModel.pausePlayback() },
|
||||
onResumePlayback = { viewModel.resumePlayback() },
|
||||
onSeekRelative = { viewModel.seekBy(it) },
|
||||
onSkipSegment = {
|
||||
viewModel.skipActiveSegment()
|
||||
},
|
||||
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null
|
||||
)
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
handled
|
||||
}
|
||||
.focusable()
|
||||
) {
|
||||
@@ -323,7 +302,7 @@ fun TvPlayerScreen(
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = playerControlsVisible,
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
@@ -335,21 +314,28 @@ fun TvPlayerScreen(
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = togglePlayPauseAndShowControls,
|
||||
onSeek = seekAndShowControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
||||
onSkipSegment = skipSegmentAndShowControls,
|
||||
onNext = nextAndShowControls,
|
||||
onPrevious = previousAndShowControls,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
onSeek = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
},
|
||||
onSeekRelative = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
},
|
||||
onSeekLiveEdge = {
|
||||
viewModel.seekToLiveEdge()
|
||||
},
|
||||
onSkipSegment = {
|
||||
viewModel.skipActiveSegment()
|
||||
},
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||
onExpandPlaylist = expandPlaylist,
|
||||
onCollapsePlaylist = collapsePlaylistToControls,
|
||||
onExpandPlaylist = ::expandPlaylist,
|
||||
onCollapsePlaylist = ::closePlaylist,
|
||||
onSelectQueueItem = { id ->
|
||||
viewModel.playQueueItem(id)
|
||||
collapsePlaylistToControls()
|
||||
},
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
@@ -358,9 +344,7 @@ fun TvPlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
visible = showSkipIntroButton,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
@@ -371,7 +355,7 @@ fun TvPlayerScreen(
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Skip segment",
|
||||
onClick = skipSegmentAndShowControls,
|
||||
onClick = { viewModel.skipActiveSegment() },
|
||||
size = 64,
|
||||
label = "Skip",
|
||||
modifier = Modifier.focusRequester(skipButtonFocusRequester)
|
||||
@@ -380,8 +364,6 @@ fun TvPlayerScreen(
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showNextEpisodeOverlay,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
@@ -392,17 +374,15 @@ fun TvPlayerScreen(
|
||||
uiState.nextEpisode?.let { nextEpisode ->
|
||||
TvNextEpisodeOverlay(
|
||||
nextEpisode = nextEpisode,
|
||||
onClick = { nextAndShowControls() },
|
||||
onUp = { showTvControls() },
|
||||
onClick = { viewModel.next() },
|
||||
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: use TimedVisibility
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
visible = showHiddenSeekPreview,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 32.dp, vertical = 28.dp)
|
||||
@@ -420,18 +400,17 @@ fun TvPlayerScreen(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
uiState = uiState,
|
||||
onRetry = { viewModel.retry() },
|
||||
onNext = nextAndShowControls,
|
||||
onNext = { viewModel.next() },
|
||||
onReplay = {
|
||||
viewModel.seekTo(0L)
|
||||
togglePlayPauseAndShowControls()
|
||||
viewModel.resumePlayback()
|
||||
},
|
||||
onDismissError = { viewModel.clearError() }
|
||||
)
|
||||
|
||||
// TODO: use TimedVisibility
|
||||
AnimatedVisibility(
|
||||
visible = stopFeedbackVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
TvPlayerHiddenStopFeedback()
|
||||
@@ -498,15 +477,8 @@ private fun HiddenTvSeekTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.coerceSeekPosition(durationMs: Long): Long =
|
||||
if (durationMs > 0L) {
|
||||
coerceIn(0L, durationMs)
|
||||
} else {
|
||||
coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
internal fun handleTvPlayerRootKeyEvent(
|
||||
event: androidx.compose.ui.input.key.KeyEvent,
|
||||
event: KeyEvent,
|
||||
isPlaying: Boolean,
|
||||
controlsVisible: Boolean,
|
||||
isPlaylistExpanded: Boolean,
|
||||
@@ -514,13 +486,12 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
onCloseTrackPanel: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onHideControls: () -> Unit,
|
||||
onPausePlaybackWithoutShowingControls: () -> Unit,
|
||||
onResumePlaybackWithoutShowingControls: () -> Unit,
|
||||
onPausePlayback: () -> Unit,
|
||||
onResumePlayback: () -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onShowControls: () -> Unit,
|
||||
onSkipSegment: () -> Unit = {},
|
||||
hasSkippableSegment: Boolean = false,
|
||||
onTogglePlayPause: () -> Unit
|
||||
hasSkippableSegment: Boolean = false
|
||||
): Boolean {
|
||||
if (event.type != KeyEventType.KeyDown) return false
|
||||
|
||||
@@ -566,9 +537,9 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
if (hasSkippableSegment) {
|
||||
onSkipSegment()
|
||||
} else if (isPlaying) {
|
||||
onPausePlaybackWithoutShowingControls()
|
||||
onPausePlayback()
|
||||
} else {
|
||||
onResumePlaybackWithoutShowingControls()
|
||||
onResumePlayback()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -29,11 +29,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -50,7 +45,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
fun TvNextEpisodeOverlay(
|
||||
nextEpisode: PlaylistElementUiModel,
|
||||
onClick: () -> Unit,
|
||||
onUp: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -88,12 +82,6 @@ fun TvNextEpisodeOverlay(
|
||||
.background(backgroundColor)
|
||||
.focusable()
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) {
|
||||
onUp()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
.clickable { onClick() },
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
|
||||
@@ -24,10 +24,10 @@ import androidx.compose.material.icons.outlined.VolumeUp
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -45,23 +45,27 @@ import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
||||
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel
|
||||
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton
|
||||
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
||||
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
||||
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
|
||||
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
@@ -69,8 +73,13 @@ fun PlayerScreen(
|
||||
viewModel: PlayerViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var controlsHasPopupOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
@@ -83,20 +92,43 @@ fun PlayerScreen(
|
||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
val overlayController = rememberPersistentOverlayController()
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
if (uiState.isPlaying) {
|
||||
showQueuePanel = false
|
||||
}
|
||||
}
|
||||
|
||||
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||
}
|
||||
|
||||
|
||||
val showSkipIntroButton = !controlsVisible && uiState.activeSkippableSegmentEndMs != null
|
||||
val showNextEpisodeOverlay = !controlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
||||
&& !uiState.isEnded
|
||||
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
controlsVisible = !controlsVisible
|
||||
}
|
||||
fun hideControls() {
|
||||
controlsVisible = false
|
||||
}
|
||||
fun hideControlsWithTimeout() {
|
||||
hideControlsJob?.cancel()
|
||||
if (controlsVisible && !controlsHasPopupOpen && !uiState.isEnded && uiState.error == null) {
|
||||
hideControlsJob = scope.launch {
|
||||
delay(CONTROLS_AUTO_HIDE_MS)
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onScreenTap() {
|
||||
toggleControlsVisibility()
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -121,7 +153,7 @@ fun PlayerScreen(
|
||||
|
||||
PlayerGesturesLayer(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
onTap = { viewModel.toggleControlsVisibility() },
|
||||
onTap = ::onScreenTap,
|
||||
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
||||
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
||||
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
||||
@@ -152,7 +184,7 @@ fun PlayerScreen(
|
||||
onHorizontalDragSeekTo = {
|
||||
viewModel.seekTo(it)
|
||||
},
|
||||
currentPositionProvider = { uiState.positionMs }
|
||||
currentPositionProvider = { uiState.positionMs },
|
||||
)
|
||||
|
||||
EmptyValueTimedVisibility(
|
||||
@@ -170,7 +202,7 @@ fun PlayerScreen(
|
||||
value = horizontalSeekPreviewPositionMs,
|
||||
hideAfterMillis = 1_000
|
||||
) { previewPositionMs ->
|
||||
if (!playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
HiddenSeekTimeline(
|
||||
positionMs = previewPositionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
@@ -225,14 +257,13 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = playerControlsVisible,
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
PlayerControlsOverlay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
showControls = controlsVisible,
|
||||
overlayController = overlayController,
|
||||
onBack = onBack,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
@@ -243,13 +274,12 @@ fun PlayerScreen(
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onSelectTrack = { viewModel.selectTrack(it) },
|
||||
onQueueSelected = { viewModel.playQueueItem(it) },
|
||||
onOpenQueue = { showQueuePanel = true }
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||
visible = showSkipIntroButton,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
@@ -262,11 +292,7 @@ fun PlayerScreen(
|
||||
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
|
||||
}
|
||||
|
||||
val showNextEpisodeOverlay = !playerControlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
||||
&& !uiState.isEnded
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showNextEpisodeOverlay,
|
||||
enter = fadeIn(),
|
||||
|
||||
@@ -32,10 +32,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.graphics.Brush
|
||||
@@ -51,7 +47,6 @@ import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
||||
@Composable
|
||||
fun PlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
showControls: Boolean,
|
||||
overlayController: PersistentOverlayController,
|
||||
onBack: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -62,11 +57,9 @@ fun PlayerControlsOverlay(
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onSelectTrack: (TrackOption) -> Unit,
|
||||
onQueueSelected: (String) -> Unit,
|
||||
onOpenQueue: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var scrubbing by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -98,11 +91,8 @@ fun PlayerControlsOverlay(
|
||||
)
|
||||
BottomSection(
|
||||
uiState = uiState,
|
||||
scrubbing = scrubbing,
|
||||
overlayController = overlayController,
|
||||
onScrubStart = { scrubbing = true },
|
||||
onScrub = onSeek,
|
||||
onScrubFinished = { scrubbing = false },
|
||||
onNext = onNext,
|
||||
onPrevious = onPrevious,
|
||||
onPlayPause = onPlayPause,
|
||||
@@ -111,7 +101,6 @@ fun PlayerControlsOverlay(
|
||||
onSeekLiveEdge = onSeekLiveEdge,
|
||||
onSkipSegment = onSkipSegment,
|
||||
onSelectTrack = onSelectTrack,
|
||||
onQueueSelected = onQueueSelected,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
@@ -163,11 +152,8 @@ private fun TopBar(
|
||||
@Composable
|
||||
private fun BottomSection(
|
||||
uiState: PlayerUiState,
|
||||
scrubbing: Boolean,
|
||||
overlayController: PersistentOverlayController,
|
||||
onScrubStart: () -> Unit,
|
||||
onScrub: (Long) -> Unit,
|
||||
onScrubFinished: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -176,7 +162,6 @@ private fun BottomSection(
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onSelectTrack: (TrackOption) -> Unit,
|
||||
onQueueSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
@@ -194,8 +179,6 @@ private fun BottomSection(
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads,
|
||||
onSeek = onScrub,
|
||||
onScrubStarted = onScrubStart,
|
||||
onScrubFinished = onScrubFinished,
|
||||
modifier = Modifier.testTag(PlayerSeekBarTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -27,8 +27,6 @@ fun PlayerSeekBar(
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
onSeek: (Long) -> Unit,
|
||||
onScrubStarted: () -> Unit,
|
||||
onScrubFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
@@ -61,7 +59,6 @@ fun PlayerSeekBar(
|
||||
onValueChange = { newValue ->
|
||||
if (!isScrubbing) {
|
||||
isScrubbing = true
|
||||
onScrubStarted()
|
||||
}
|
||||
sliderPosition = newValue
|
||||
},
|
||||
@@ -69,7 +66,6 @@ fun PlayerSeekBar(
|
||||
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
||||
isScrubbing = false
|
||||
onSeek(targetPosition)
|
||||
onScrubFinished()
|
||||
},
|
||||
valueRange = 0f..safeDuration.toFloat(),
|
||||
colors = SliderDefaults.colors(
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
enum class ControlsAutoHideBlocker {
|
||||
PLAYLIST,
|
||||
TRACK_PANEL
|
||||
}
|
||||
|
||||
internal sealed interface ControlsAutoHideCommand {
|
||||
data object Cancel : ControlsAutoHideCommand
|
||||
data class Schedule(val delayMs: Long) : ControlsAutoHideCommand
|
||||
}
|
||||
|
||||
internal class ControlsAutoHidePolicy(
|
||||
private val defaultDelayMs: Long
|
||||
) {
|
||||
private val blockers = mutableSetOf<ControlsAutoHideBlocker>()
|
||||
private var isPlaying = false
|
||||
|
||||
var controlsVisible: Boolean = true
|
||||
private set
|
||||
|
||||
var lastAutoHideDelayMs: Long = defaultDelayMs
|
||||
private set
|
||||
|
||||
fun onPlaybackChanged(isPlaying: Boolean): ControlsAutoHideCommand {
|
||||
this.isPlaying = isPlaying
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun setAutoHideDelay(delayMs: Long): ControlsAutoHideCommand {
|
||||
lastAutoHideDelayMs = delayMs
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun showControls(delayMs: Long? = null): ControlsAutoHideCommand {
|
||||
delayMs?.let { lastAutoHideDelayMs = it }
|
||||
controlsVisible = true
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility(): ControlsAutoHideCommand {
|
||||
controlsVisible = !controlsVisible
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun hideControls(): ControlsAutoHideCommand {
|
||||
controlsVisible = false
|
||||
return ControlsAutoHideCommand.Cancel
|
||||
}
|
||||
|
||||
fun setBlocked(
|
||||
blocker: ControlsAutoHideBlocker,
|
||||
blocked: Boolean
|
||||
): ControlsAutoHideCommand {
|
||||
if (blocked) {
|
||||
blockers += blocker
|
||||
controlsVisible = true
|
||||
} else {
|
||||
blockers -= blocker
|
||||
}
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
private fun nextCommand(): ControlsAutoHideCommand {
|
||||
return if (!controlsVisible || !isPlaying || blockers.isNotEmpty()) {
|
||||
ControlsAutoHideCommand.Cancel
|
||||
} else {
|
||||
ControlsAutoHideCommand.Schedule(lastAutoHideDelayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.model.PlayableMedia
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -22,8 +21,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -46,10 +45,6 @@ class PlayerViewModel @Inject constructor(
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _controlsVisible = MutableStateFlow(true)
|
||||
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
|
||||
|
||||
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
|
||||
private val seekByCollector = SeekByCollector(viewModelScope) { deltaMs ->
|
||||
playerManager.seekBy(deltaMs)
|
||||
}
|
||||
@@ -77,12 +72,6 @@ class PlayerViewModel @Inject constructor(
|
||||
error = state.error ?: dataErrorMessage
|
||||
)
|
||||
}
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying)
|
||||
)
|
||||
if (state.isEnded) {
|
||||
showControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +177,8 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
fun togglePlayPause() {
|
||||
playerManager.togglePlayPause()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
@@ -220,49 +208,14 @@ class PlayerViewModel @Inject constructor(
|
||||
playerManager.skipActiveSegment()
|
||||
}
|
||||
|
||||
fun setControlsAutoHideBlocked(
|
||||
blocker: ControlsAutoHideBlocker,
|
||||
blocked: Boolean
|
||||
) {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.setBlocked(blocker, blocked)
|
||||
)
|
||||
}
|
||||
|
||||
fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.showControls(autoHideDelayMs)
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.toggleControlsVisibility()
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) {
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
autoHideJob?.cancel()
|
||||
autoHideJob = null
|
||||
if (command !is ControlsAutoHideCommand.Schedule) return
|
||||
autoHideJob = viewModelScope.launch {
|
||||
delay(command.delayMs)
|
||||
controlsAutoHidePolicy.hideControls()
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
}
|
||||
}
|
||||
|
||||
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
fun next() {
|
||||
seekByCollector.clear()
|
||||
playerManager.next()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
fun previous() {
|
||||
seekByCollector.clear()
|
||||
playerManager.previous()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun selectTrack(option: TrackOption) {
|
||||
@@ -276,7 +229,6 @@ class PlayerViewModel @Inject constructor(
|
||||
fun playQueueItem(id: String) {
|
||||
seekByCollector.clear()
|
||||
playerManager.play(id.toUuidOrNull() ?: return)
|
||||
showControls()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ControlsAutoHidePolicyTest {
|
||||
|
||||
@Test
|
||||
fun activeBlockerCancelsScheduledAutoHide() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(3_500L),
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
)
|
||||
assertTrue(policy.controlsVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistBlockerPreventsAutoHideUntilCleared() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackPanelBlockerUsesRememberedDelayWhenRemoved() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoHideResumesOnlyAfterLastBlockerIsCleared() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingStateMakesHiddenControlsVisibleAgain() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.toggleControlsVisibility()
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
)
|
||||
assertTrue(policy.controlsVisible)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user