mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
2 Commits
360cd51e23
...
a0af187e8f
| Author | SHA1 | Date | |
|---|---|---|---|
| a0af187e8f | |||
| 16839942b1 |
@@ -1,6 +1,8 @@
|
|||||||
package hu.bbara.purefin.tv.player.components
|
package hu.bbara.purefin.tv.player.components
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -10,12 +12,17 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.semantics.SemanticsProperties
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
import androidx.compose.ui.test.SemanticsMatcher
|
import androidx.compose.ui.test.SemanticsMatcher
|
||||||
import androidx.compose.ui.test.assert
|
import androidx.compose.ui.test.assert
|
||||||
@@ -37,17 +44,48 @@ import hu.bbara.purefin.core.player.model.PlayerUiState
|
|||||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
import hu.bbara.purefin.core.player.model.QueueItemUi
|
||||||
import hu.bbara.purefin.core.player.model.TrackOption
|
import hu.bbara.purefin.core.player.model.TrackOption
|
||||||
import hu.bbara.purefin.core.player.model.TrackType
|
import hu.bbara.purefin.core.player.model.TrackType
|
||||||
|
import hu.bbara.purefin.tv.player.TV_HIDDEN_STOP_FEEDBACK_MS
|
||||||
|
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedback
|
||||||
|
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedbackTag
|
||||||
import hu.bbara.purefin.tv.player.handleTvPlayerRootKeyEvent
|
import hu.bbara.purefin.tv.player.handleTvPlayerRootKeyEvent
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
private const val TvPlayerRootTag = "tv_player_root"
|
||||||
|
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
|
||||||
|
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
class TvPlayerControlsOverlayTest {
|
class TvPlayerControlsOverlayTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
OverlayHost(
|
||||||
|
uiState = samplePlayerState(),
|
||||||
|
requestFocus = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||||
|
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
||||||
|
.assert(
|
||||||
|
SemanticsMatcher.expectValue(
|
||||||
|
SemanticsProperties.Focused,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
|
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
@@ -323,6 +361,137 @@ class TvPlayerControlsOverlayTest {
|
|||||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
TrackPanelHost(
|
||||||
|
uiState = samplePlayerState(),
|
||||||
|
initialControlsVisible = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||||
|
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||||
|
.performKeyInput {
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||||
|
.assert(
|
||||||
|
SemanticsMatcher.expectValue(
|
||||||
|
SemanticsProperties.StateDescription,
|
||||||
|
"1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
TrackPanelHost(
|
||||||
|
uiState = samplePlayerState(),
|
||||||
|
initialControlsVisible = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||||
|
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||||
|
.performKeyInput {
|
||||||
|
pressKey(Key.Enter)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||||
|
.assert(
|
||||||
|
SemanticsMatcher.expectValue(
|
||||||
|
SemanticsProperties.StateDescription,
|
||||||
|
"1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() {
|
||||||
|
composeRule.mainClock.autoAdvance = false
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
TrackPanelHost(
|
||||||
|
uiState = samplePlayerState(),
|
||||||
|
initialControlsVisible = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||||
|
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||||
|
.performKeyInput {
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.mainClock.advanceTimeByFrame()
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||||
|
|
||||||
|
composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1)
|
||||||
|
composeRule.mainClock.advanceTimeByFrame()
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||||
|
|
||||||
|
composeRule.mainClock.advanceTimeBy(1)
|
||||||
|
composeRule.mainClock.advanceTimeBy(300)
|
||||||
|
composeRule.mainClock.advanceTimeByFrame()
|
||||||
|
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||||
|
|
||||||
|
composeRule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
TrackPanelHost(
|
||||||
|
uiState = samplePlayerState().copy(isPlaying = false),
|
||||||
|
initialControlsVisible = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||||
|
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||||
|
.performKeyInput {
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||||
|
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||||
|
.assert(
|
||||||
|
SemanticsMatcher.expectValue(
|
||||||
|
SemanticsProperties.StateDescription,
|
||||||
|
"0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag)
|
||||||
|
.assert(
|
||||||
|
SemanticsMatcher.expectValue(
|
||||||
|
SemanticsProperties.StateDescription,
|
||||||
|
"1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
@@ -433,7 +602,8 @@ class TvPlayerControlsOverlayTest {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun OverlayHost(
|
private fun OverlayHost(
|
||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
initiallyExpanded: Boolean = false
|
initiallyExpanded: Boolean = false,
|
||||||
|
requestFocus: Boolean = false
|
||||||
) {
|
) {
|
||||||
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
@@ -441,6 +611,12 @@ private fun OverlayHost(
|
|||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(requestFocus) {
|
||||||
|
if (requestFocus) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
|
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
|
||||||
TvPlayerControlsOverlay(
|
TvPlayerControlsOverlay(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
@@ -470,11 +646,17 @@ private fun OverlayHost(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackPanelHost(
|
private fun TrackPanelHost(
|
||||||
uiState: PlayerUiState
|
uiState: PlayerUiState,
|
||||||
|
initialControlsVisible: Boolean = true
|
||||||
) {
|
) {
|
||||||
var controlsVisible by remember { mutableStateOf(true) }
|
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
|
||||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
|
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
||||||
|
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||||
|
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
|
||||||
|
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
|
||||||
|
val rootFocusRequester = remember { FocusRequester() }
|
||||||
val controlsFocusRequester = remember { FocusRequester() }
|
val controlsFocusRequester = remember { FocusRequester() }
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||||
@@ -486,6 +668,15 @@ private fun TrackPanelHost(
|
|||||||
controlsVisible = true
|
controlsVisible = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
||||||
|
pauseWithoutControlsCount += 1
|
||||||
|
stopFeedbackVisible = true
|
||||||
|
stopFeedbackRequestId += 1
|
||||||
|
}
|
||||||
|
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
||||||
|
resumeWithoutControlsCount += 1
|
||||||
|
stopFeedbackVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||||
@@ -498,18 +689,38 @@ private fun TrackPanelHost(
|
|||||||
pendingTrackButtonFocus = null
|
pendingTrackButtonFocus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(stopFeedbackRequestId) {
|
||||||
|
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||||
|
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||||
|
stopFeedbackVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(controlsVisible, trackPanelType) {
|
||||||
|
if (controlsVisible || trackPanelType != null) {
|
||||||
|
stopFeedbackVisible = false
|
||||||
|
} else {
|
||||||
|
rootFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 960.dp, height = 540.dp)
|
.size(width = 960.dp, height = 540.dp)
|
||||||
|
.focusRequester(rootFocusRequester)
|
||||||
|
.focusable()
|
||||||
|
.testTag(TvPlayerRootTag)
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
handleTvPlayerRootKeyEvent(
|
handleTvPlayerRootKeyEvent(
|
||||||
event = event,
|
event = event,
|
||||||
|
isPlaying = uiState.isPlaying,
|
||||||
controlsVisible = controlsVisible,
|
controlsVisible = controlsVisible,
|
||||||
isPlaylistExpanded = false,
|
isPlaylistExpanded = false,
|
||||||
trackPanelType = trackPanelType,
|
trackPanelType = trackPanelType,
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
onCloseTrackPanel = closeTrackPanel,
|
||||||
onCollapsePlaylist = { controlsVisible = true },
|
onCollapsePlaylist = { controlsVisible = true },
|
||||||
onHideControls = { controlsVisible = false },
|
onHideControls = { controlsVisible = false },
|
||||||
|
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||||
|
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||||
onSeekRelative = { _ -> controlsVisible = true },
|
onSeekRelative = { _ -> controlsVisible = true },
|
||||||
onShowControls = { controlsVisible = true },
|
onShowControls = { controlsVisible = true },
|
||||||
onTogglePlayPause = { controlsVisible = true }
|
onTogglePlayPause = { controlsVisible = true }
|
||||||
@@ -551,6 +762,25 @@ private fun TrackPanelHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = stopFeedbackVisible,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
) {
|
||||||
|
TvPlayerHiddenStopFeedback()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TvPlayerHiddenPauseCountTag)
|
||||||
|
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TvPlayerHiddenResumeCountTag)
|
||||||
|
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
|
||||||
|
)
|
||||||
|
|
||||||
trackPanelType?.let { panelType ->
|
trackPanelType?.let { panelType ->
|
||||||
TvTrackSelectionPanel(
|
TvTrackSelectionPanel(
|
||||||
panelType = panelType,
|
panelType = panelType,
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import androidx.compose.foundation.focusable
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -24,6 +31,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -33,6 +41,8 @@ import androidx.compose.ui.input.key.key
|
|||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -44,8 +54,11 @@ import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay
|
|||||||
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
|
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
|
||||||
import hu.bbara.purefin.tv.player.components.TvTrackPanelType
|
import hu.bbara.purefin.tv.player.components.TvTrackPanelType
|
||||||
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
|
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||||
|
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
|
||||||
|
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -63,6 +76,8 @@ fun TvPlayerScreen(
|
|||||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
|
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
||||||
|
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -118,6 +133,15 @@ fun TvPlayerScreen(
|
|||||||
val togglePlayPauseAndShowControls: () -> Unit = {
|
val togglePlayPauseAndShowControls: () -> Unit = {
|
||||||
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
||||||
}
|
}
|
||||||
|
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
||||||
|
viewModel.pausePlayback()
|
||||||
|
stopFeedbackVisible = true
|
||||||
|
stopFeedbackRequestId += 1
|
||||||
|
}
|
||||||
|
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
||||||
|
viewModel.resumePlayback()
|
||||||
|
stopFeedbackVisible = false
|
||||||
|
}
|
||||||
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
||||||
viewModel.seekTo(positionMs)
|
viewModel.seekTo(positionMs)
|
||||||
showTvControls()
|
showTvControls()
|
||||||
@@ -164,6 +188,18 @@ fun TvPlayerScreen(
|
|||||||
pendingTrackButtonFocus = null
|
pendingTrackButtonFocus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(stopFeedbackRequestId) {
|
||||||
|
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||||
|
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||||
|
stopFeedbackVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
|
||||||
|
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
|
||||||
|
stopFeedbackVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
BackHandler(enabled = true) {
|
||||||
when {
|
when {
|
||||||
trackPanelType != null -> closeTrackPanel()
|
trackPanelType != null -> closeTrackPanel()
|
||||||
@@ -181,12 +217,15 @@ fun TvPlayerScreen(
|
|||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
handleTvPlayerRootKeyEvent(
|
handleTvPlayerRootKeyEvent(
|
||||||
event = event,
|
event = event,
|
||||||
|
isPlaying = uiState.isPlaying,
|
||||||
controlsVisible = controlsVisible,
|
controlsVisible = controlsVisible,
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
isPlaylistExpanded = isPlaylistExpanded,
|
||||||
trackPanelType = trackPanelType,
|
trackPanelType = trackPanelType,
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
onCloseTrackPanel = closeTrackPanel,
|
||||||
onCollapsePlaylist = collapsePlaylistToControls,
|
onCollapsePlaylist = collapsePlaylistToControls,
|
||||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
onHideControls = { viewModel.toggleControlsVisibility() },
|
||||||
|
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||||
|
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||||
onSeekRelative = seekByAndShowControls,
|
onSeekRelative = seekByAndShowControls,
|
||||||
onShowControls = showTvControls,
|
onShowControls = showTvControls,
|
||||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
onTogglePlayPause = togglePlayPauseAndShowControls
|
||||||
@@ -254,6 +293,15 @@ fun TvPlayerScreen(
|
|||||||
onDismissError = { viewModel.clearError() }
|
onDismissError = { viewModel.clearError() }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = stopFeedbackVisible,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
) {
|
||||||
|
TvPlayerHiddenStopFeedback()
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = trackPanelType != null,
|
visible = trackPanelType != null,
|
||||||
enter = slideInHorizontally { it },
|
enter = slideInHorizontally { it },
|
||||||
@@ -277,12 +325,15 @@ fun TvPlayerScreen(
|
|||||||
|
|
||||||
internal fun handleTvPlayerRootKeyEvent(
|
internal fun handleTvPlayerRootKeyEvent(
|
||||||
event: androidx.compose.ui.input.key.KeyEvent,
|
event: androidx.compose.ui.input.key.KeyEvent,
|
||||||
|
isPlaying: Boolean,
|
||||||
controlsVisible: Boolean,
|
controlsVisible: Boolean,
|
||||||
isPlaylistExpanded: Boolean,
|
isPlaylistExpanded: Boolean,
|
||||||
trackPanelType: TvTrackPanelType?,
|
trackPanelType: TvTrackPanelType?,
|
||||||
onCloseTrackPanel: () -> Unit,
|
onCloseTrackPanel: () -> Unit,
|
||||||
onCollapsePlaylist: () -> Unit,
|
onCollapsePlaylist: () -> Unit,
|
||||||
onHideControls: () -> Unit,
|
onHideControls: () -> Unit,
|
||||||
|
onPausePlaybackWithoutShowingControls: () -> Unit,
|
||||||
|
onResumePlaybackWithoutShowingControls: () -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onShowControls: () -> Unit,
|
onShowControls: () -> Unit,
|
||||||
onTogglePlayPause: () -> Unit
|
onTogglePlayPause: () -> Unit
|
||||||
@@ -328,7 +379,11 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionCenter, Key.Enter -> {
|
Key.DirectionCenter, Key.Enter -> {
|
||||||
onTogglePlayPause()
|
if (isPlaying) {
|
||||||
|
onPausePlaybackWithoutShowingControls()
|
||||||
|
} else {
|
||||||
|
onResumePlaybackWithoutShowingControls()
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,3 +393,24 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TvPlayerHiddenStopFeedback(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.testTag(TvPlayerHiddenStopFeedbackTag)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.72f))
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Pause,
|
||||||
|
contentDescription = "Pause playback",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ private fun TvPlayerBottomSection(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val playPauseFocusRequester = remember { FocusRequester() }
|
|
||||||
val playlistFocusRequester = remember { FocusRequester() }
|
val playlistFocusRequester = remember { FocusRequester() }
|
||||||
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
|
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
|
||||||
|
|
||||||
@@ -194,7 +193,7 @@ private fun TvPlayerBottomSection(
|
|||||||
if (isPlaylistExpanded && uiState.queue.isNotEmpty()) {
|
if (isPlaylistExpanded && uiState.queue.isNotEmpty()) {
|
||||||
playlistFocusRequester.requestFocus()
|
playlistFocusRequester.requestFocus()
|
||||||
} else if (wasPlaylistExpanded) {
|
} else if (wasPlaylistExpanded) {
|
||||||
playPauseFocusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
wasPlaylistExpanded = isPlaylistExpanded
|
wasPlaylistExpanded = isPlaylistExpanded
|
||||||
}
|
}
|
||||||
@@ -255,10 +254,9 @@ private fun TvPlayerBottomSection(
|
|||||||
onSeekRelative = onSeekRelative,
|
onSeekRelative = onSeekRelative,
|
||||||
togglePlayState = onPlayPause,
|
togglePlayState = onPlayPause,
|
||||||
onMoveDown = {
|
onMoveDown = {
|
||||||
playPauseFocusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
focusRequester = focusRequester,
|
|
||||||
modifier = Modifier.testTag(TvPlayerSeekBarTag)
|
modifier = Modifier.testTag(TvPlayerSeekBarTag)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -292,7 +290,7 @@ private fun TvPlayerBottomSection(
|
|||||||
onClick = onPlayPause,
|
onClick = onPlayPause,
|
||||||
size = 72,
|
size = 72,
|
||||||
modifier = expandPlaylistModifier
|
modifier = expandPlaylistModifier
|
||||||
.focusRequester(playPauseFocusRequester)
|
.focusRequester(focusRequester)
|
||||||
.testTag(TvPlayerPlayPauseButtonTag)
|
.testTag(TvPlayerPlayPauseButtonTag)
|
||||||
)
|
)
|
||||||
TvIconButton(
|
TvIconButton(
|
||||||
|
|||||||
@@ -139,6 +139,18 @@ class PlayerManager @Inject constructor(
|
|||||||
if (player.isPlaying) player.pause() else player.play()
|
if (player.isPlaying) player.pause() else player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pausePlayback() {
|
||||||
|
if (player.isPlaying) {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumePlayback() {
|
||||||
|
if (!player.isPlaying) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
val target = clampSeekPosition(positionMs)
|
val target = clampSeekPosition(positionMs)
|
||||||
pendingSeekPositionMs = target
|
pendingSeekPositionMs = target
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ class PlayerViewModel @Inject constructor(
|
|||||||
showControls(autoHideDelayMs)
|
showControls(autoHideDelayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pausePlayback() {
|
||||||
|
playerManager.pausePlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumePlayback() {
|
||||||
|
playerManager.resumePlayback()
|
||||||
|
}
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
playerManager.seekTo(positionMs)
|
playerManager.seekTo(positionMs)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user