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.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
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
|
||||||
@@ -41,34 +42,36 @@ 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
|
||||||
import androidx.compose.ui.input.key.Key
|
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.KeyEventType
|
||||||
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.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.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
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.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
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.core.player.viewmodel.PlayerViewModel
|
||||||
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
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.TvIconButton
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay
|
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.TvPlayerControlsOverlay
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
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.TvTrackPanelType
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
|
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
|
||||||
@@ -86,21 +89,28 @@ fun TvPlayerScreen(
|
|||||||
viewModel.loadMedia(mediaId)
|
viewModel.loadMedia(mediaId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
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 isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
|
// TODO why this is needed???
|
||||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
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 hiddenSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||||
var hiddenSeekRequestId by remember { mutableStateOf(0) }
|
var hiddenSeekRequestId by remember { mutableIntStateOf(0) }
|
||||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val rootFocusRequester = remember { FocusRequester() }
|
val backgroundFocusRequester = remember { FocusRequester() }
|
||||||
val controlsFocusRequester = remember { FocusRequester() }
|
|
||||||
|
// Main section focus requesters
|
||||||
|
var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) }
|
||||||
|
var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) }
|
||||||
|
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||||
@@ -110,7 +120,11 @@ fun TvPlayerScreen(
|
|||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
viewModel.pausePlayback()
|
viewModel.pausePlayback()
|
||||||
}
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(uiState.isPlaying) {
|
LaunchedEffect(uiState.isPlaying) {
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
if (uiState.isPlaying) {
|
if (uiState.isPlaying) {
|
||||||
@@ -119,118 +133,73 @@ fun TvPlayerScreen(
|
|||||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
fun showControls() {
|
||||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
controlsVisible = true
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
|
}
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
|
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) {
|
fun expandPlaylist() {
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
|
// TODO: focus management for playlist expansion
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(trackPanelType) {
|
|
||||||
viewModel.setControlsAutoHideBlocked(
|
|
||||||
ControlsAutoHideBlocker.TRACK_PANEL,
|
|
||||||
trackPanelType != null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val expandPlaylist: () -> Unit = {
|
|
||||||
if (!isPlaylistExpanded) {
|
|
||||||
isPlaylistExpanded = true
|
isPlaylistExpanded = true
|
||||||
}
|
}
|
||||||
}
|
fun closePlaylist() {
|
||||||
val collapsePlaylistToControls: () -> Unit = {
|
// TODO focus
|
||||||
if (isPlaylistExpanded) {
|
|
||||||
isPlaylistExpanded = false
|
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 = {
|
val closeTrackPanel: () -> Unit = {
|
||||||
trackPanelType?.let { panelType ->
|
trackPanelType?.let { panelType ->
|
||||||
pendingTrackButtonFocus = panelType
|
pendingTrackButtonFocus = panelType
|
||||||
trackPanelType = null
|
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.nextEpisode != null
|
||||||
&& uiState.durationMs > 0L
|
&& uiState.durationMs > 0L
|
||||||
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
||||||
&& !uiState.isEnded
|
&& !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) {
|
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||||
@@ -243,6 +212,7 @@ fun TvPlayerScreen(
|
|||||||
pendingTrackButtonFocus = null
|
pendingTrackButtonFocus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: use animated visibility for this type of feedback that is already implemented
|
||||||
LaunchedEffect(stopFeedbackRequestId) {
|
LaunchedEffect(stopFeedbackRequestId) {
|
||||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||||
@@ -263,7 +233,7 @@ fun TvPlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val subtitleBottomPaddingFraction =
|
val subtitleBottomPaddingFraction =
|
||||||
if (playerControlsVisible) {
|
if (controlsVisible) {
|
||||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||||
} else {
|
} else {
|
||||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||||
@@ -272,8 +242,12 @@ fun TvPlayerScreen(
|
|||||||
BackHandler(enabled = true) {
|
BackHandler(enabled = true) {
|
||||||
when {
|
when {
|
||||||
trackPanelType != null -> closeTrackPanel()
|
trackPanelType != null -> closeTrackPanel()
|
||||||
isPlaylistExpanded -> collapsePlaylistToControls()
|
isPlaylistExpanded -> {
|
||||||
controlsVisible -> viewModel.toggleControlsVisibility()
|
closePlaylist()
|
||||||
|
}
|
||||||
|
controlsVisible -> {
|
||||||
|
hideControls()
|
||||||
|
}
|
||||||
else -> onBack()
|
else -> onBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,23 +258,28 @@ fun TvPlayerScreen(
|
|||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.focusRequester(rootFocusRequester)
|
.focusRequester(rootFocusRequester)
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
handleTvPlayerRootKeyEvent(
|
val handled = handleTvPlayerRootKeyEvent(
|
||||||
event = event,
|
event = event,
|
||||||
isPlaying = uiState.isPlaying,
|
isPlaying = uiState.isPlaying,
|
||||||
controlsVisible = controlsVisible,
|
controlsVisible = controlsVisible,
|
||||||
|
onShowControls = ::showControls,
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
isPlaylistExpanded = isPlaylistExpanded,
|
||||||
trackPanelType = trackPanelType,
|
trackPanelType = trackPanelType,
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
onCloseTrackPanel = closeTrackPanel,
|
||||||
onCollapsePlaylist = collapsePlaylistToControls,
|
onCollapsePlaylist = {},
|
||||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
onHideControls = ::hideControls,
|
||||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
onPausePlayback = { viewModel.pausePlayback() },
|
||||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
onResumePlayback = { viewModel.resumePlayback() },
|
||||||
onSeekRelative = seekByWithoutShowingControls,
|
onSeekRelative = { viewModel.seekBy(it) },
|
||||||
onShowControls = showTvControls,
|
onSkipSegment = {
|
||||||
onSkipSegment = skipSegmentAndShowControls,
|
viewModel.skipActiveSegment()
|
||||||
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null,
|
},
|
||||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null
|
||||||
)
|
)
|
||||||
|
if (event.type == KeyEventType.KeyDown) {
|
||||||
|
hideControlsWithTimeout()
|
||||||
|
}
|
||||||
|
handled
|
||||||
}
|
}
|
||||||
.focusable()
|
.focusable()
|
||||||
) {
|
) {
|
||||||
@@ -323,7 +302,7 @@ fun TvPlayerScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = playerControlsVisible,
|
visible = controlsVisible,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
@@ -335,21 +314,28 @@ fun TvPlayerScreen(
|
|||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||||
onPlayPause = togglePlayPauseAndShowControls,
|
onPlayPause = { viewModel.togglePlayPause() },
|
||||||
onSeek = seekAndShowControls,
|
onSeek = { positionMs ->
|
||||||
onSeekRelative = seekByAndShowControls,
|
viewModel.seekTo(positionMs)
|
||||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
},
|
||||||
onSkipSegment = skipSegmentAndShowControls,
|
onSeekRelative = { deltaMs ->
|
||||||
onNext = nextAndShowControls,
|
viewModel.seekBy(deltaMs)
|
||||||
onPrevious = previousAndShowControls,
|
},
|
||||||
|
onSeekLiveEdge = {
|
||||||
|
viewModel.seekToLiveEdge()
|
||||||
|
},
|
||||||
|
onSkipSegment = {
|
||||||
|
viewModel.skipActiveSegment()
|
||||||
|
},
|
||||||
|
onNext = { viewModel.next() },
|
||||||
|
onPrevious = { viewModel.previous() },
|
||||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||||
onExpandPlaylist = expandPlaylist,
|
onExpandPlaylist = ::expandPlaylist,
|
||||||
onCollapsePlaylist = collapsePlaylistToControls,
|
onCollapsePlaylist = ::closePlaylist,
|
||||||
onSelectQueueItem = { id ->
|
onSelectQueueItem = { id ->
|
||||||
viewModel.playQueueItem(id)
|
viewModel.playQueueItem(id)
|
||||||
collapsePlaylistToControls()
|
|
||||||
},
|
},
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||||
@@ -358,9 +344,7 @@ fun TvPlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
visible = showSkipIntroButton,
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(
|
.padding(
|
||||||
@@ -371,7 +355,7 @@ fun TvPlayerScreen(
|
|||||||
TvIconButton(
|
TvIconButton(
|
||||||
icon = Icons.Outlined.SkipNext,
|
icon = Icons.Outlined.SkipNext,
|
||||||
contentDescription = "Skip segment",
|
contentDescription = "Skip segment",
|
||||||
onClick = skipSegmentAndShowControls,
|
onClick = { viewModel.skipActiveSegment() },
|
||||||
size = 64,
|
size = 64,
|
||||||
label = "Skip",
|
label = "Skip",
|
||||||
modifier = Modifier.focusRequester(skipButtonFocusRequester)
|
modifier = Modifier.focusRequester(skipButtonFocusRequester)
|
||||||
@@ -380,8 +364,6 @@ fun TvPlayerScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showNextEpisodeOverlay,
|
visible = showNextEpisodeOverlay,
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(
|
.padding(
|
||||||
@@ -392,17 +374,15 @@ fun TvPlayerScreen(
|
|||||||
uiState.nextEpisode?.let { nextEpisode ->
|
uiState.nextEpisode?.let { nextEpisode ->
|
||||||
TvNextEpisodeOverlay(
|
TvNextEpisodeOverlay(
|
||||||
nextEpisode = nextEpisode,
|
nextEpisode = nextEpisode,
|
||||||
onClick = { nextAndShowControls() },
|
onClick = { viewModel.next() },
|
||||||
onUp = { showTvControls() },
|
|
||||||
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
|
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: use TimedVisibility
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null,
|
visible = showHiddenSeekPreview,
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(horizontal = 32.dp, vertical = 28.dp)
|
.padding(horizontal = 32.dp, vertical = 28.dp)
|
||||||
@@ -420,18 +400,17 @@ fun TvPlayerScreen(
|
|||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onRetry = { viewModel.retry() },
|
onRetry = { viewModel.retry() },
|
||||||
onNext = nextAndShowControls,
|
onNext = { viewModel.next() },
|
||||||
onReplay = {
|
onReplay = {
|
||||||
viewModel.seekTo(0L)
|
viewModel.seekTo(0L)
|
||||||
togglePlayPauseAndShowControls()
|
viewModel.resumePlayback()
|
||||||
},
|
},
|
||||||
onDismissError = { viewModel.clearError() }
|
onDismissError = { viewModel.clearError() }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: use TimedVisibility
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = stopFeedbackVisible,
|
visible = stopFeedbackVisible,
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
) {
|
) {
|
||||||
TvPlayerHiddenStopFeedback()
|
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(
|
internal fun handleTvPlayerRootKeyEvent(
|
||||||
event: androidx.compose.ui.input.key.KeyEvent,
|
event: KeyEvent,
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
controlsVisible: Boolean,
|
controlsVisible: Boolean,
|
||||||
isPlaylistExpanded: Boolean,
|
isPlaylistExpanded: Boolean,
|
||||||
@@ -514,13 +486,12 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
onCloseTrackPanel: () -> Unit,
|
onCloseTrackPanel: () -> Unit,
|
||||||
onCollapsePlaylist: () -> Unit,
|
onCollapsePlaylist: () -> Unit,
|
||||||
onHideControls: () -> Unit,
|
onHideControls: () -> Unit,
|
||||||
onPausePlaybackWithoutShowingControls: () -> Unit,
|
onPausePlayback: () -> Unit,
|
||||||
onResumePlaybackWithoutShowingControls: () -> Unit,
|
onResumePlayback: () -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onShowControls: () -> Unit,
|
onShowControls: () -> Unit,
|
||||||
onSkipSegment: () -> Unit = {},
|
onSkipSegment: () -> Unit = {},
|
||||||
hasSkippableSegment: Boolean = false,
|
hasSkippableSegment: Boolean = false
|
||||||
onTogglePlayPause: () -> Unit
|
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (event.type != KeyEventType.KeyDown) return false
|
if (event.type != KeyEventType.KeyDown) return false
|
||||||
|
|
||||||
@@ -566,9 +537,9 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
if (hasSkippableSegment) {
|
if (hasSkippableSegment) {
|
||||||
onSkipSegment()
|
onSkipSegment()
|
||||||
} else if (isPlaying) {
|
} else if (isPlaying) {
|
||||||
onPausePlaybackWithoutShowingControls()
|
onPausePlayback()
|
||||||
} else {
|
} else {
|
||||||
onResumePlaybackWithoutShowingControls()
|
onResumePlayback()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -50,7 +45,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
|||||||
fun TvNextEpisodeOverlay(
|
fun TvNextEpisodeOverlay(
|
||||||
nextEpisode: PlaylistElementUiModel,
|
nextEpisode: PlaylistElementUiModel,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onUp: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -88,12 +82,6 @@ fun TvNextEpisodeOverlay(
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.focusable()
|
.focusable()
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.onPreviewKeyEvent { event ->
|
|
||||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) {
|
|
||||||
onUp()
|
|
||||||
true
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
.clickable { onClick() },
|
.clickable { onClick() },
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
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.rememberCoroutineScope
|
||||||
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
|
||||||
@@ -45,23 +45,27 @@ import hu.bbara.purefin.core.player.model.TimedMarker
|
|||||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||||
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
||||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
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.PersistentOverlayContainer
|
||||||
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
|
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.PlayerControlsOverlay
|
||||||
import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer
|
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.PlayerLoadingErrorEndCard
|
||||||
import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel
|
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.PlayerSeekBarTrack
|
||||||
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
|
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.SkipSegmentButton
|
||||||
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
|
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.abs
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
||||||
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
||||||
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
|
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
|
||||||
|
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -69,8 +73,13 @@ fun PlayerScreen(
|
|||||||
viewModel: PlayerViewModel,
|
viewModel: PlayerViewModel,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
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 context = LocalContext.current
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
|
|
||||||
@@ -83,20 +92,43 @@ fun PlayerScreen(
|
|||||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||||
val overlayController = rememberPersistentOverlayController()
|
val overlayController = rememberPersistentOverlayController()
|
||||||
|
|
||||||
LaunchedEffect(uiState.isPlaying) {
|
|
||||||
if (uiState.isPlaying) {
|
|
||||||
showQueuePanel = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
|
|
||||||
val subtitleBottomPaddingFraction =
|
val subtitleBottomPaddingFraction =
|
||||||
if (playerControlsVisible) {
|
if (controlsVisible) {
|
||||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||||
} else {
|
} else {
|
||||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -121,7 +153,7 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
PlayerGesturesLayer(
|
PlayerGesturesLayer(
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
onTap = { viewModel.toggleControlsVisibility() },
|
onTap = ::onScreenTap,
|
||||||
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
||||||
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
||||||
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
||||||
@@ -152,7 +184,7 @@ fun PlayerScreen(
|
|||||||
onHorizontalDragSeekTo = {
|
onHorizontalDragSeekTo = {
|
||||||
viewModel.seekTo(it)
|
viewModel.seekTo(it)
|
||||||
},
|
},
|
||||||
currentPositionProvider = { uiState.positionMs }
|
currentPositionProvider = { uiState.positionMs },
|
||||||
)
|
)
|
||||||
|
|
||||||
EmptyValueTimedVisibility(
|
EmptyValueTimedVisibility(
|
||||||
@@ -170,7 +202,7 @@ fun PlayerScreen(
|
|||||||
value = horizontalSeekPreviewPositionMs,
|
value = horizontalSeekPreviewPositionMs,
|
||||||
hideAfterMillis = 1_000
|
hideAfterMillis = 1_000
|
||||||
) { previewPositionMs ->
|
) { previewPositionMs ->
|
||||||
if (!playerControlsVisible) {
|
if (controlsVisible) {
|
||||||
HiddenSeekTimeline(
|
HiddenSeekTimeline(
|
||||||
positionMs = previewPositionMs,
|
positionMs = previewPositionMs,
|
||||||
durationMs = uiState.durationMs,
|
durationMs = uiState.durationMs,
|
||||||
@@ -225,14 +257,13 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = playerControlsVisible,
|
visible = controlsVisible,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
PlayerControlsOverlay(
|
PlayerControlsOverlay(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
showControls = controlsVisible,
|
|
||||||
overlayController = overlayController,
|
overlayController = overlayController,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onPlayPause = { viewModel.togglePlayPause() },
|
onPlayPause = { viewModel.togglePlayPause() },
|
||||||
@@ -243,13 +274,12 @@ fun PlayerScreen(
|
|||||||
onNext = { viewModel.next() },
|
onNext = { viewModel.next() },
|
||||||
onPrevious = { viewModel.previous() },
|
onPrevious = { viewModel.previous() },
|
||||||
onSelectTrack = { viewModel.selectTrack(it) },
|
onSelectTrack = { viewModel.selectTrack(it) },
|
||||||
onQueueSelected = { viewModel.playQueueItem(it) },
|
|
||||||
onOpenQueue = { showQueuePanel = true }
|
onOpenQueue = { showQueuePanel = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
visible = showSkipIntroButton,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -262,11 +292,7 @@ fun PlayerScreen(
|
|||||||
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
|
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
|
||||||
}
|
}
|
||||||
|
|
||||||
val showNextEpisodeOverlay = !playerControlsVisible
|
|
||||||
&& uiState.nextEpisode != null
|
|
||||||
&& uiState.durationMs > 0L
|
|
||||||
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
|
||||||
&& !uiState.isEnded
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showNextEpisodeOverlay,
|
visible = showNextEpisodeOverlay,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
|
|||||||
@@ -32,10 +32,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
@@ -51,7 +47,6 @@ import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
|||||||
@Composable
|
@Composable
|
||||||
fun PlayerControlsOverlay(
|
fun PlayerControlsOverlay(
|
||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
showControls: Boolean,
|
|
||||||
overlayController: PersistentOverlayController,
|
overlayController: PersistentOverlayController,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
@@ -62,11 +57,9 @@ fun PlayerControlsOverlay(
|
|||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onSelectTrack: (TrackOption) -> Unit,
|
onSelectTrack: (TrackOption) -> Unit,
|
||||||
onQueueSelected: (String) -> Unit,
|
|
||||||
onOpenQueue: () -> Unit,
|
onOpenQueue: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var scrubbing by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -98,11 +91,8 @@ fun PlayerControlsOverlay(
|
|||||||
)
|
)
|
||||||
BottomSection(
|
BottomSection(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
scrubbing = scrubbing,
|
|
||||||
overlayController = overlayController,
|
overlayController = overlayController,
|
||||||
onScrubStart = { scrubbing = true },
|
|
||||||
onScrub = onSeek,
|
onScrub = onSeek,
|
||||||
onScrubFinished = { scrubbing = false },
|
|
||||||
onNext = onNext,
|
onNext = onNext,
|
||||||
onPrevious = onPrevious,
|
onPrevious = onPrevious,
|
||||||
onPlayPause = onPlayPause,
|
onPlayPause = onPlayPause,
|
||||||
@@ -111,7 +101,6 @@ fun PlayerControlsOverlay(
|
|||||||
onSeekLiveEdge = onSeekLiveEdge,
|
onSeekLiveEdge = onSeekLiveEdge,
|
||||||
onSkipSegment = onSkipSegment,
|
onSkipSegment = onSkipSegment,
|
||||||
onSelectTrack = onSelectTrack,
|
onSelectTrack = onSelectTrack,
|
||||||
onQueueSelected = onQueueSelected,
|
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -163,11 +152,8 @@ private fun TopBar(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun BottomSection(
|
private fun BottomSection(
|
||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
scrubbing: Boolean,
|
|
||||||
overlayController: PersistentOverlayController,
|
overlayController: PersistentOverlayController,
|
||||||
onScrubStart: () -> Unit,
|
|
||||||
onScrub: (Long) -> Unit,
|
onScrub: (Long) -> Unit,
|
||||||
onScrubFinished: () -> Unit,
|
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
@@ -176,7 +162,6 @@ private fun BottomSection(
|
|||||||
onSeekLiveEdge: () -> Unit,
|
onSeekLiveEdge: () -> Unit,
|
||||||
onSkipSegment: () -> Unit,
|
onSkipSegment: () -> Unit,
|
||||||
onSelectTrack: (TrackOption) -> Unit,
|
onSelectTrack: (TrackOption) -> Unit,
|
||||||
onQueueSelected: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
@@ -194,8 +179,6 @@ private fun BottomSection(
|
|||||||
chapterMarkers = uiState.chapters,
|
chapterMarkers = uiState.chapters,
|
||||||
adMarkers = uiState.ads,
|
adMarkers = uiState.ads,
|
||||||
onSeek = onScrub,
|
onSeek = onScrub,
|
||||||
onScrubStarted = onScrubStart,
|
|
||||||
onScrubFinished = onScrubFinished,
|
|
||||||
modifier = Modifier.testTag(PlayerSeekBarTag)
|
modifier = Modifier.testTag(PlayerSeekBarTag)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ fun PlayerSeekBar(
|
|||||||
chapterMarkers: List<TimedMarker>,
|
chapterMarkers: List<TimedMarker>,
|
||||||
adMarkers: List<TimedMarker>,
|
adMarkers: List<TimedMarker>,
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onScrubStarted: () -> Unit,
|
|
||||||
onScrubFinished: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||||
@@ -61,7 +59,6 @@ fun PlayerSeekBar(
|
|||||||
onValueChange = { newValue ->
|
onValueChange = { newValue ->
|
||||||
if (!isScrubbing) {
|
if (!isScrubbing) {
|
||||||
isScrubbing = true
|
isScrubbing = true
|
||||||
onScrubStarted()
|
|
||||||
}
|
}
|
||||||
sliderPosition = newValue
|
sliderPosition = newValue
|
||||||
},
|
},
|
||||||
@@ -69,7 +66,6 @@ fun PlayerSeekBar(
|
|||||||
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
||||||
isScrubbing = false
|
isScrubbing = false
|
||||||
onSeek(targetPosition)
|
onSeek(targetPosition)
|
||||||
onScrubFinished()
|
|
||||||
},
|
},
|
||||||
valueRange = 0f..safeDuration.toFloat(),
|
valueRange = 0f..safeDuration.toFloat(),
|
||||||
colors = SliderDefaults.colors(
|
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.core.player.model.TrackOption
|
||||||
import hu.bbara.purefin.model.PlayableMedia
|
import hu.bbara.purefin.model.PlayableMedia
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -22,8 +21,8 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
|
||||||
import kotlinx.serialization.InternalSerializationApi
|
import kotlinx.serialization.InternalSerializationApi
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -46,10 +45,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
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 ->
|
private val seekByCollector = SeekByCollector(viewModelScope) { deltaMs ->
|
||||||
playerManager.seekBy(deltaMs)
|
playerManager.seekBy(deltaMs)
|
||||||
}
|
}
|
||||||
@@ -77,12 +72,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
error = state.error ?: dataErrorMessage
|
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()
|
playerManager.togglePlayPause()
|
||||||
showControls(autoHideDelayMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pausePlayback() {
|
fun pausePlayback() {
|
||||||
@@ -220,49 +208,14 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playerManager.skipActiveSegment()
|
playerManager.skipActiveSegment()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setControlsAutoHideBlocked(
|
fun next() {
|
||||||
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) {
|
|
||||||
seekByCollector.clear()
|
seekByCollector.clear()
|
||||||
playerManager.next()
|
playerManager.next()
|
||||||
showControls(autoHideDelayMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
fun previous() {
|
||||||
seekByCollector.clear()
|
seekByCollector.clear()
|
||||||
playerManager.previous()
|
playerManager.previous()
|
||||||
showControls(autoHideDelayMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectTrack(option: TrackOption) {
|
fun selectTrack(option: TrackOption) {
|
||||||
@@ -276,7 +229,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun playQueueItem(id: String) {
|
fun playQueueItem(id: String) {
|
||||||
seekByCollector.clear()
|
seekByCollector.clear()
|
||||||
playerManager.play(id.toUuidOrNull() ?: return)
|
playerManager.play(id.toUuidOrNull() ?: return)
|
||||||
showControls()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
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