diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt new file mode 100644 index 00000000..f4a24829 --- /dev/null +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt @@ -0,0 +1,759 @@ +package com.kino.puber.ui.feature.player.component + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isFocused +import androidx.compose.ui.test.isFocusable +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onParent +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.lifecycle.Lifecycle +import androidx.test.platform.app.InstrumentationRegistry +import com.kino.puber.core.ui.uikit.component.moviesList.VideoGridItemUIState +import com.kino.puber.core.ui.uikit.component.moviesList.VideoGridUIState +import com.kino.puber.core.ui.uikit.component.moviesList.VideoItemUIState +import com.kino.puber.core.ui.uikit.model.UIAction +import com.kino.puber.core.ui.uikit.theme.PuberTheme +import com.kino.puber.domain.model.SubtitleSize +import com.kino.puber.ui.feature.player.model.ActivePanel +import com.kino.puber.ui.feature.player.model.AudioTrackUIState +import com.kino.puber.ui.feature.player.model.BufferPreset +import com.kino.puber.ui.feature.player.model.BufferPresetUIState +import com.kino.puber.ui.feature.player.model.FocusTarget +import com.kino.puber.ui.feature.player.model.PlayerAction +import com.kino.puber.ui.feature.player.model.PlayerContentState +import com.kino.puber.ui.feature.player.model.PlayerViewState +import com.kino.puber.ui.feature.player.model.QualityUIState +import com.kino.puber.ui.feature.player.model.ResumeDialogState +import com.kino.puber.ui.feature.player.model.SoundModeUIState +import com.kino.puber.ui.feature.player.model.SpeedUIState +import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState +import com.kino.puber.ui.feature.player.vm.ControlsStateMachine +import com.kino.puber.ui.feature.player.vm.PlaybackIntent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +private const val AUDIO_BUTTON = "Аудио и субтитры" +private const val VIDEO_BUTTON = "Видео" +private const val EPISODES_BUTTON = "Серии" +private const val SOUND_ITEM = "Стерео 2.0" +private const val QUALITY_ITEM = "1080p" +private const val EPISODE_ITEM = "1. Первое включение" +private const val MARK_WATCHED_BUTTON = "Просмотрено" + +internal class PlayerScreenContentFocusTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun initialControlsBackMovesFocusToPlayer_andDpadDownReopensFirstButton() { + val harness = render() + + assertExactlyOneFocusedNode() + assertFocusedControl() + assertPlayerAnchorFocusable(expected = false) + assertFocusSurvivesRecomposition(harness, ::assertFocusedControl) + + sendKey(KeyEvent.KEYCODE_BACK) + waitForControlsToBeDisposed() + + assertEquals(0, harness.exitRequestCount) + assertExactlyOneFocusedNode() + assertFocusedPlayerSurface() + assertPlayerAnchorFocusable(expected = true) + assertHiddenControlsAreNotFocused() + + sendKey(KeyEvent.KEYCODE_DPAD_DOWN) + + assertExactlyOneFocusedNode() + assertFocusedControl() + assertControlsVisible(harness) + } + + @Test + fun playerToControlsHandoff_keepsPlayerFocused_untilDelayedControlFocusSucceeds() { + val harness = render() + hideControls(harness) + composeRule.mainClock.autoAdvance = false + + try { + sendKeyWithoutSettling(KeyEvent.KEYCODE_DPAD_DOWN) + composeRule.mainClock.advanceTimeByFrame() + composeRule.waitForIdle() + + assertTrue(harness.content.controlsVisible) + assertExactlyOneFocusedNode() + assertFocusedPlayerSurface() + assertPlayerAnchorFocusable(expected = true) + + composeRule.mainClock.advanceTimeByFrame() + composeRule.waitForIdle() + + assertExactlyOneFocusedNode() + assertFocusedControl() + assertHiddenPlayerIsNotFocused() + + composeRule.mainClock.advanceTimeByFrame() + composeRule.waitForIdle() + + assertExactlyOneFocusedNode() + assertFocusedControl() + assertPlayerAnchorFocusable(expected = false) + } finally { + composeRule.mainClock.autoAdvance = true + } + } + + @Test + fun resumeDialogOwnsFocus_thenSelectionTransfersToPlayerAnchor() { + val harness = render(content(resumeDialog = resumeDialog())) + + composeRule.onNodeWithText("Продолжить").assertIsFocused() + assertExactlyOneFocusedNode() + assertPlayerAnchorFocusable(expected = false) + assertFocusSurvivesRecomposition(harness) { + composeRule.onNodeWithText("Продолжить").assertIsFocused() + } + + composeRule.onNodeWithText("Продолжить").performKeyInput { + keyDown(androidx.compose.ui.input.key.Key.Enter) + keyUp(androidx.compose.ui.input.key.Key.Enter) + } + composeRule.waitForIdle() + + assertTrue(harness.content.resumeDialog == null) + assertExactlyOneFocusedNode() + assertFocusedPlayerSurface() + assertPlayerAnchorFocusable(expected = true) + } + + @Test + fun hiddenPlayer_dpadUpReopensSeekBar_andDpadDownReopensButtons() { + val harness = render() + hideControls(harness) + + sendKey(KeyEvent.KEYCODE_DPAD_UP) + assertExactlyOneFocusedNode() + assertFocusedSeekBar() + assertEquals(listOf(FocusTarget.SeekBar), harness.revealedTargets) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_DPAD_DOWN) + assertExactlyOneFocusedNode() + assertFocusedControl() + assertEquals( + listOf(FocusTarget.SeekBar, FocusTarget.Buttons), + harness.revealedTargets, + ) + } + + @Test + fun hiddenPlayer_directionalSeekPerformsOneAction_andFocusesSeekBar() { + val harness = render() + hideControls(harness) + + sendKey(KeyEvent.KEYCODE_DPAD_LEFT) + assertExactlyOneFocusedNode() + assertFocusedSeekBar() + assertEquals(1, harness.seekBackwardCount) + assertEquals(0, harness.seekForwardCount) + assertEquals(listOf(FocusTarget.SeekBar), harness.revealedTargets) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_DPAD_RIGHT) + assertExactlyOneFocusedNode() + assertFocusedSeekBar() + assertEquals(1, harness.seekBackwardCount) + assertEquals(1, harness.seekForwardCount) + assertEquals( + listOf(FocusTarget.SeekBar, FocusTarget.SeekBar), + harness.revealedTargets, + ) + } + + @Test + fun hiddenPlayer_dpadCenterPerformsOnePlaybackAction_andFocusesFirstButton() { + val harness = render() + hideControls(harness) + + sendKey(KeyEvent.KEYCODE_DPAD_CENTER) + + assertExactlyOneFocusedNode() + assertFocusedControl() + assertEquals(1, harness.togglePlayPauseCount) + assertEquals(listOf(FocusTarget.Buttons), harness.revealedTargets) + } + + @Test + fun hiddenPlayer_enterPerformsOnePlaybackAction_andFocusesFirstButton() { + val harness = render() + hideControls(harness) + + sendKey(KeyEvent.KEYCODE_ENTER) + + assertExactlyOneFocusedNode() + assertFocusedControl() + assertEquals(1, harness.togglePlayPauseCount) + assertEquals(listOf(FocusTarget.Buttons), harness.revealedTargets) + } + + @Test + fun hiddenPlayer_mediaSeekAndPlaybackKeysPerformOneAction_andKeepTargetContract() { + val harness = render() + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_MEDIA_REWIND) + assertFocusedSeekBar() + assertEquals(1, harness.seekBackwardCount) + assertEquals(1, harness.revealedTargets.size) + assertEquals(FocusTarget.SeekBar, harness.revealedTargets.single()) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) + assertFocusedSeekBar() + assertEquals(1, harness.seekForwardCount) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD) + assertFocusedSeekBar() + assertEquals(2, harness.seekBackwardCount) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD) + assertFocusedSeekBar() + assertEquals(2, harness.seekForwardCount) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + assertFocusedControl() + assertEquals(1, harness.togglePlayPauseCount) + assertEquals(FocusTarget.Buttons, harness.revealedTargets.last()) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_MEDIA_PLAY) + assertFocusedControl() + assertEquals(2, harness.togglePlayPauseCount) + + hideControls(harness) + sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE) + assertFocusedControl() + assertEquals(3, harness.togglePlayPauseCount) + } + + @Test + fun controlsTraversal_opensAudioPanel_andBackRestoresAudioButton() { + val harness = render() + + focusButton(AUDIO_BUTTON, steps = 1) + sendKey(KeyEvent.KEYCODE_ENTER) + + composeRule.onNodeWithText(SOUND_ITEM).assertIsFocused() + assertExactlyOneFocusedNode() + assertPlayerAnchorFocusable(expected = false) + composeRule.onNodeWithText(AUDIO_BUTTON).assertDoesNotExist() + assertFocusSurvivesRecomposition(harness) { + composeRule.onNodeWithText(SOUND_ITEM).assertIsFocused() + } + + sendKey(KeyEvent.KEYCODE_BACK) + + assertExactlyOneFocusedNode() + waitUntilFocusedText(AUDIO_BUTTON) + assertEquals(ActivePanel.None, harness.content.activePanel) + } + + @Test + fun controlsTraversal_opensVideoPanel_andBackRestoresVideoButton() { + val harness = render() + + focusButton(VIDEO_BUTTON, steps = 2) + sendKey(KeyEvent.KEYCODE_ENTER) + + composeRule.onNodeWithText(QUALITY_ITEM).assertIsFocused() + assertExactlyOneFocusedNode() + assertPlayerAnchorFocusable(expected = false) + + sendKey(KeyEvent.KEYCODE_BACK) + + assertExactlyOneFocusedNode() + waitUntilFocusedText(VIDEO_BUTTON) + assertEquals(ActivePanel.None, harness.content.activePanel) + } + + @Test + fun seriesEpisodesPanel_focusesCurrentEpisode_andBackRestoresEpisodesButton() { + val harness = render(content(isMovie = false)) + + focusButton(EPISODES_BUTTON, steps = 1) + sendKey(KeyEvent.KEYCODE_ENTER) + + composeRule.onNodeWithText(EPISODE_ITEM).assertIsFocused() + assertExactlyOneFocusedNode() + assertPlayerAnchorFocusable(expected = false) + + sendKey(KeyEvent.KEYCODE_BACK) + + assertExactlyOneFocusedNode() + waitUntilFocusedText(EPISODES_BUTTON) + assertEquals(ActivePanel.None, harness.content.activePanel) + } + + @Test + fun controlsTraversal_movesFromSeekBarToButtonRow_withOneFocusedNode() { + val harness = render() + hideControls(harness) + + sendKey(KeyEvent.KEYCODE_DPAD_UP) + assertFocusedSeekBar() + + sendKey(KeyEvent.KEYCODE_DPAD_DOWN) + assertExactlyOneFocusedNode() + assertFocusedControl() + assertTrue(harness.content.controlsVisible) + } + + @Test + fun playbackEndedReveal_focusesFirstButton_andPlayerAnchorLosesFocus() { + val harness = render(content(isMovie = false)) + hideControls(harness) + + harness.revealControls(FocusTarget.Buttons) + + assertExactlyOneFocusedNode() + assertFocusedControl() + assertHiddenPlayerIsNotFocused() + assertEquals(FocusTarget.Buttons, harness.content.controlsFocusTarget) + } + + @Test + fun hiddenDisabledExitingAndDetachedControls_neverOwnFocus() { + val harness = render( + content( + canMarkCurrentWatched = true, + isMarkCurrentWatchedInFlight = true, + ), + ) + val disabledControl = composeRule + .onNodeWithText(MARK_WATCHED_BUTTON, useUnmergedTree = true) + .onParent() + .onParent() + + disabledControl.assertIsNotEnabled() + disabledControl.assertIsNotFocused() + assertExactlyOneFocusedNode() + assertFocusedControl() + + composeRule.mainClock.autoAdvance = false + composeRule.runOnIdle { + harness.onAction(PlayerAction.HideControls) + } + composeRule.mainClock.advanceTimeBy(FOCUS_TRANSFER_SETTLE_MS) + composeRule.waitForIdle() + + assertFalse(harness.content.controlsVisible) + composeRule.onNodeWithText(AUDIO_BUTTON).assertExists().assertIsNotFocused() + disabledControl.assertExists().assertIsNotEnabled().assertIsNotFocused() + assertExactlyOneFocusedNode() + assertFocusedPlayerSurface() + + composeRule.mainClock.advanceTimeBy(CONTROLS_EXIT_SETTLE_MS) + composeRule.waitForIdle() + + composeRule.onNodeWithText(AUDIO_BUTTON).assertDoesNotExist() + composeRule.onNodeWithText(MARK_WATCHED_BUTTON).assertDoesNotExist() + assertExactlyOneFocusedNode() + assertFocusedPlayerSurface() + composeRule.mainClock.autoAdvance = true + } + + @Test + fun hiddenSecondBack_exitsOnce_andRecompositionKeepsPlayerFocus() { + val harness = render() + hideControls(harness) + + assertFocusedPlayerSurface() + assertFocusSurvivesRecomposition(harness, ::assertFocusedPlayerSurface) + + sendKey(KeyEvent.KEYCODE_BACK) + assertEquals(1, harness.exitRequestCount) + assertExactlyOneFocusedNode() + + composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + composeRule.waitForIdle() + assertExactlyOneFocusedNode() + assertFocusedPlayerSurface() + + sendKey(KeyEvent.KEYCODE_DPAD_DOWN) + assertFocusedControl() + assertEquals(FocusTarget.Buttons, harness.revealedTargets.last()) + } + + private fun render( + initialContent: PlayerContentState = content(), + ): PlayerHarness { + val harness = PlayerHarness(initialContent) + composeRule.setContent { + PuberTheme { + PlayerScreenContent( + state = harness.state.value, + onAction = harness::onAction, + exoPlayer = { null }, + ) + } + } + composeRule.runOnIdle { harness.installBackHandler(composeRule.activity) } + composeRule.waitForIdle() + return harness + } + + private fun hideControls(harness: PlayerHarness) { + harness.onAction(PlayerAction.HideControls) + composeRule.waitForIdle() + assertFocusedPlayerSurface() + } + + private fun focusButton(text: String, steps: Int) { + repeat(steps) { + sendKey(KeyEvent.KEYCODE_DPAD_RIGHT) + } + composeRule.onNodeWithText(text).assertIsFocused() + } + + private fun assertFocusSurvivesRecomposition( + harness: PlayerHarness, + assertFocusedOwner: () -> Unit, + ) { + harness.bumpPosition() + composeRule.waitForIdle() + assertExactlyOneFocusedNode() + assertFocusedOwner() + } + + private fun assertExactlyOneFocusedNode() { + assertEquals( + "PlayerScreen must have exactly one focused semantics node", + 1, + composeRule.onAllNodes(isFocused(), useUnmergedTree = true) + .fetchSemanticsNodes() + .size, + ) + } + + private fun assertFocusedPlayerSurface() { + val focusedNode = focusedNode() + val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + assertTrue( + "Focused player anchor must cover the root width", + focusedNode.boundsInRoot.width >= rootBounds.width * 0.9f, + ) + assertTrue( + "Focused player anchor must cover the root height", + focusedNode.boundsInRoot.height >= rootBounds.height * 0.9f, + ) + } + + private fun assertFocusedSeekBar() { + val focusedNode = focusedNode() + val rootWidth = composeRule.onRoot().fetchSemanticsNode().boundsInRoot.width + assertTrue( + "Seek bar must be the focused wide control: node=${focusedNode.boundsInRoot}, root=$rootWidth", + focusedNode.boundsInRoot.width >= rootWidth * 0.5f, + ) + assertTrue( + "Seek bar must not be the full-screen player anchor: node=${focusedNode.boundsInRoot}, root=$rootWidth", + focusedNode.boundsInRoot.width < rootWidth * 0.9f, + ) + } + + private fun assertFocusedControl() { + waitUntilFocusedTarget { node -> + val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + node.boundsInRoot.width < rootBounds.width * 0.5f && + node.boundsInRoot.height < rootBounds.height * 0.9f + } + val focusedNode = focusedNode() + val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + assertTrue( + "Control focus must not remain on the full-screen player anchor", + focusedNode.boundsInRoot.width < rootBounds.width * 0.5f && + focusedNode.boundsInRoot.height < rootBounds.height * 0.9f, + ) + } + + private fun assertHiddenControlsAreNotFocused() { + composeRule.onNodeWithText(AUDIO_BUTTON).assertDoesNotExist() + composeRule.onNodeWithText(VIDEO_BUTTON).assertDoesNotExist() + } + + private fun assertHiddenPlayerIsNotFocused() { + val focusedNode = focusedNode() + val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + assertFalse( + "Player anchor must lose focus after controls reopen", + focusedNode.boundsInRoot.width >= rootBounds.width * 0.9f, + ) + } + + private fun assertPlayerAnchorFocusable(expected: Boolean) { + val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + val fullScreenFocusableCount = composeRule + .onAllNodes(isFocusable(), useUnmergedTree = true) + .fetchSemanticsNodes() + .count { node -> + node.boundsInRoot.width >= rootBounds.width * 0.9f && + node.boundsInRoot.height >= rootBounds.height * 0.9f + } + assertEquals( + "Only the typed Player owner may expose the full-screen focus anchor", + if (expected) 1 else 0, + fullScreenFocusableCount, + ) + } + + private fun focusedNode() = composeRule + .onAllNodes(isFocused(), useUnmergedTree = true) + .fetchSemanticsNodes() + .single() + + private fun waitUntilFocusedTarget(predicate: (androidx.compose.ui.semantics.SemanticsNode) -> Boolean) { + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(isFocused(), useUnmergedTree = true) + .fetchSemanticsNodes() + .singleOrNull() + ?.let(predicate) == true + } + } + + private fun waitUntilFocusedText(text: String) { + composeRule.waitUntil(timeoutMillis = 5_000) { + runCatching { + composeRule.onNodeWithText(text) + .fetchSemanticsNode() + .config + .getOrNull(SemanticsProperties.Focused) == true + }.getOrDefault(false) + } + composeRule.onNodeWithText(text).assertIsFocused() + } + + private fun assertControlsVisible(harness: PlayerHarness) { + assertTrue(harness.content.controlsVisible) + composeRule.onNodeWithText(AUDIO_BUTTON).assertIsDisplayed() + } + + private fun waitForControlsToBeDisposed() { + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes( + hasText(AUDIO_BUTTON), + useUnmergedTree = true, + ).fetchSemanticsNodes().isEmpty() + } + } + + private fun sendKey(keyCode: Int) { + sendKeyWithoutSettling(keyCode) + composeRule.waitForIdle() + } + + private fun sendKeyWithoutSettling(keyCode: Int) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.sendKeySync(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + instrumentation.sendKeySync(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + } + + private fun content( + controlsVisible: Boolean = true, + activePanel: ActivePanel = ActivePanel.None, + isMovie: Boolean = true, + resumeDialog: ResumeDialogState? = null, + canMarkCurrentWatched: Boolean = false, + isMarkCurrentWatchedInFlight: Boolean = false, + ) = PlayerContentState( + title = "Test", + subtitle = null, + isPlaying = false, + playbackIntent = PlaybackIntent.Paused, + shouldKeepScreenOn = false, + currentPosition = 30_000L, + duration = 600_000L, + bufferedPosition = 60_000L, + controlsVisible = controlsVisible, + controlsFocusTarget = if (controlsVisible) FocusTarget.Buttons else null, + activePanel = activePanel, + seekIndicator = null, + playPauseIndicator = null, + audioTracks = listOf(AudioTrackUIState(0, "Русский", "ru")), + selectedAudioTrackIndex = 0, + subtitleTracks = listOf(SubtitleTrackUIState(0, "Выкл.", "", "")), + selectedSubtitleIndex = 0, + soundModes = listOf(SoundModeUIState(0, SOUND_ITEM)), + selectedSoundModeIndex = 0, + subtitleSize = SubtitleSize.MEDIUM, + qualities = listOf(QualityUIState(0, QUALITY_ITEM, 1080, 1920, 1080)), + selectedQualityIndex = 0, + speeds = listOf(SpeedUIState(0, "Норм.", 1f)), + selectedSpeedIndex = 0, + aspectRatios = emptyList(), + selectedAspectRatioIndex = 0, + bufferPresets = listOf(BufferPresetUIState(0, "Авто", BufferPreset.AUTO)), + selectedBufferPresetIndex = 0, + isMovie = isMovie, + hasNextEpisode = false, + hasPreviousEpisode = false, + canMarkCurrentWatched = canMarkCurrentWatched, + isCurrentMediaWatched = false, + isMarkCurrentWatchedInFlight = isMarkCurrentWatchedInFlight, + nextEpisodeCountdown = null, + resumeDialog = resumeDialog, + episodes = if (isMovie) null else episodes(), + currentEpisodeId = if (isMovie) null else CURRENT_EPISODE_ID, + ) + + private fun episodes() = VideoGridUIState( + list = listOf( + VideoGridItemUIState.Title("1 сезон"), + VideoGridItemUIState.Items( + items = listOf( + VideoItemUIState( + id = CURRENT_EPISODE_ID, + title = EPISODE_ITEM, + imageUrl = "", + bigImageUrl = "", + showTitle = true, + seasonNumber = 1, + episodeNumber = 1, + ), + ), + rowKey = "season_1", + ), + ), + ) + + private fun resumeDialog() = ResumeDialogState( + savedPosition = 30_000L, + formattedTime = "0:30", + episodeInfo = null, + ) + + private class PlayerHarness( + initialContent: PlayerContentState, + ) { + val machine = ControlsStateMachine() + val state: MutableState + val revealedTargets = mutableListOf() + var seekForwardCount = 0 + private set + var seekBackwardCount = 0 + private set + var togglePlayPauseCount = 0 + private set + var exitRequestCount = 0 + private set + + val content: PlayerContentState + get() = (state.value as PlayerViewState.Content).content + + init { + machine.initialize(initialContent.resumeDialog != null) + state = mutableStateOf( + PlayerViewState.Content(initialContent.withControlsState(machine.state)), + ) + } + + fun installBackHandler(activity: ComponentActivity) { + activity.onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val effects = machine.handleBack() + if (effects.any { it is ControlsStateMachine.Effect.SaveAndExit }) { + exitRequestCount++ + } else { + publishMachineState() + } + } + }, + ) + } + + fun onAction(action: UIAction) { + when (action) { + is PlayerAction.ShowControls -> revealControls(action.focusTarget) + PlayerAction.HideControls -> { + machine.hideControls() + publishMachineState() + } + PlayerAction.SeekForward -> seekForwardCount++ + PlayerAction.SeekBackward -> seekBackwardCount++ + PlayerAction.TogglePlayPause -> togglePlayPauseCount++ + PlayerAction.OpenAudioSubtitlesPanel -> openPanel(ActivePanel.AudioSubtitles) + PlayerAction.OpenVideoSettingsPanel -> openPanel(ActivePanel.VideoSettings) + PlayerAction.OpenEpisodesPanel -> openPanel(ActivePanel.Episodes) + PlayerAction.ClosePanel -> { + machine.closePanel() + publishMachineState() + } + PlayerAction.ResumeFromPosition, + PlayerAction.StartFromBeginning -> { + state.value = PlayerViewState.Content( + content.copy(resumeDialog = null), + ) + } + else -> Unit + } + } + + fun revealControls(target: FocusTarget) { + machine.showControls(target) + revealedTargets += target + publishMachineState() + } + + fun bumpPosition() { + state.value = PlayerViewState.Content( + content.copy(currentPosition = content.currentPosition + 1), + ) + } + + private fun openPanel(panel: ActivePanel) { + machine.openPanel(panel, content.playbackIntent) + publishMachineState() + } + + private fun publishMachineState() { + state.value = PlayerViewState.Content( + content.withControlsState(machine.state), + ) + } + + private fun PlayerContentState.withControlsState( + machineState: ControlsStateMachine.State, + ) = copy( + controlsVisible = machineState.controlsVisible, + controlsFocusTarget = machineState.focusTarget, + activePanel = machineState.activePanel, + ) + } + + private companion object { + const val CURRENT_EPISODE_ID = 101 + const val FOCUS_TRANSFER_SETTLE_MS = 64L + const val CONTROLS_EXIT_SETTLE_MS = 1_000L + } +} diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt index b0a72378..8ba52001 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt @@ -3,7 +3,6 @@ package com.kino.puber.ui.feature.player.component import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -41,8 +40,6 @@ internal class PlayerVideoSurfaceTest { PlayerVideoSurface( content = contentState.value, exoPlayer = { player }, - onAction = {}, - focusRequester = FocusRequester(), playerViewFactory = { context -> player = player ?: ExoPlayer.Builder(context).build() PlayerView(context).also(playerViewReference::set) @@ -114,8 +111,6 @@ internal class PlayerVideoSurfaceTest { PlayerVideoSurface( content = contentState.value, exoPlayer = { player }, - onAction = {}, - focusRequester = FocusRequester(), playerViewFactory = { context -> player = ExoPlayer.Builder(context).build() object : PlayerView(context) { @@ -164,8 +159,6 @@ internal class PlayerVideoSurfaceTest { PlayerVideoSurface( content = contentState.value, exoPlayer = { player }, - onAction = {}, - focusRequester = FocusRequester(), playerViewFactory = { context -> player = player ?: ExoPlayer.Builder(context).build() PlayerView(context).also(playerViewReference::set) diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt index 5490c8f1..d4a56fe9 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -35,6 +36,7 @@ import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState @Composable internal fun AudioSubtitlesPanel( visible: Boolean, + isFocusOwner: Boolean, soundModes: List, selectedSoundModeIndex: Int, audioTracks: List, @@ -54,8 +56,18 @@ internal fun AudioSubtitlesPanel( enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }), ) { + val initialFocusTarget = when { + soundModes.isNotEmpty() -> AudioSubtitlesFocusTarget.SoundMode + audioTracks.isNotEmpty() -> AudioSubtitlesFocusTarget.AudioTrack + subtitleTracks.isNotEmpty() -> AudioSubtitlesFocusTarget.Subtitle + else -> AudioSubtitlesFocusTarget.SubtitleSize + } + val panelFocusRequester = rememberRequestingFocusRequester( + focusKey = initialFocusTarget, + isFocusOwner = isFocusOwner, + ) + AudioSubtitlesPanelContainer { - val panelFocusRequester = rememberRequestingFocusRequester() AudioSubtitlesColumns( soundModes = soundModes, selectedSoundModeIndex = selectedSoundModeIndex, @@ -64,15 +76,28 @@ internal fun AudioSubtitlesPanel( subtitleTracks = subtitleTracks, selectedSubtitleIndex = selectedSubtitleIndex, panelFocusRequester = panelFocusRequester, + initialFocusTarget = initialFocusTarget, onSoundModeSelected = onSoundModeSelected, onAudioTrackSelected = onAudioTrackSelected, onSubtitleSelected = onSubtitleSelected, ) - SubtitleSizeButton(onClick = onSubtitleSizeClick) + SubtitleSizeButton( + onClick = onSubtitleSizeClick, + focusRequester = panelFocusRequester.takeIf { + initialFocusTarget == AudioSubtitlesFocusTarget.SubtitleSize + }, + ) } } } +private enum class AudioSubtitlesFocusTarget { + SoundMode, + AudioTrack, + Subtitle, + SubtitleSize, +} + @Composable private fun AudioSubtitlesPanelContainer(content: @Composable BoxScope.() -> Unit) { Box( @@ -101,6 +126,7 @@ private fun AudioSubtitlesColumns( subtitleTracks: List, selectedSubtitleIndex: Int, panelFocusRequester: FocusRequester, + initialFocusTarget: AudioSubtitlesFocusTarget, onSoundModeSelected: (Int) -> Unit, onAudioTrackSelected: (Int) -> Unit, onSubtitleSelected: (Int) -> Unit, @@ -111,9 +137,24 @@ private fun AudioSubtitlesColumns( .padding(top = 24.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly, ) { - SoundModeColumn(soundModes, selectedSoundModeIndex, onSoundModeSelected) - AudioTrackColumn(audioTracks, selectedAudioTrackIndex, panelFocusRequester, onAudioTrackSelected) - SubtitleColumn(subtitleTracks, selectedSubtitleIndex, onSubtitleSelected) + SoundModeColumn( + soundModes, + selectedSoundModeIndex, + panelFocusRequester.takeIf { initialFocusTarget == AudioSubtitlesFocusTarget.SoundMode }, + onSoundModeSelected, + ) + AudioTrackColumn( + audioTracks, + selectedAudioTrackIndex, + panelFocusRequester.takeIf { initialFocusTarget == AudioSubtitlesFocusTarget.AudioTrack }, + onAudioTrackSelected, + ) + SubtitleColumn( + subtitleTracks, + selectedSubtitleIndex, + panelFocusRequester.takeIf { initialFocusTarget == AudioSubtitlesFocusTarget.Subtitle }, + onSubtitleSelected, + ) } } @@ -121,6 +162,7 @@ private fun AudioSubtitlesColumns( private fun RowScope.SoundModeColumn( soundModes: List, selectedSoundModeIndex: Int, + panelFocusRequester: FocusRequester?, onSoundModeSelected: (Int) -> Unit, ) { if (soundModes.isEmpty()) return @@ -131,6 +173,7 @@ private fun RowScope.SoundModeColumn( selectedIndex = selectedSoundModeIndex, onItemSelected = onSoundModeSelected, modifier = Modifier.weight(1f), + firstItemFocusRequester = panelFocusRequester, ) } @@ -138,7 +181,7 @@ private fun RowScope.SoundModeColumn( private fun RowScope.AudioTrackColumn( audioTracks: List, selectedAudioTrackIndex: Int, - panelFocusRequester: FocusRequester, + panelFocusRequester: FocusRequester?, onAudioTrackSelected: (Int) -> Unit, ) { if (audioTracks.isEmpty()) return @@ -157,6 +200,7 @@ private fun RowScope.AudioTrackColumn( private fun RowScope.SubtitleColumn( subtitleTracks: List, selectedSubtitleIndex: Int, + panelFocusRequester: FocusRequester?, onSubtitleSelected: (Int) -> Unit, ) { val labels = remember(subtitleTracks) { subtitleTracks.map { it.label } } @@ -166,16 +210,21 @@ private fun RowScope.SubtitleColumn( selectedIndex = selectedSubtitleIndex, onItemSelected = onSubtitleSelected, modifier = Modifier.weight(1f), + firstItemFocusRequester = panelFocusRequester, ) } @Composable -private fun BoxScope.SubtitleSizeButton(onClick: () -> Unit) { +private fun BoxScope.SubtitleSizeButton( + onClick: () -> Unit, + focusRequester: FocusRequester?, +) { Button( onClick = onClick, modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 16.dp, end = 16.dp), + .padding(top = 16.dp, end = 16.dp) + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier), colors = ButtonDefaults.colors( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PanelFocusRequester.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PanelFocusRequester.kt index 3d35d7a4..26c4d235 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PanelFocusRequester.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PanelFocusRequester.kt @@ -2,14 +2,79 @@ package com.kino.puber.ui.feature.player.component import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.focus.FocusRequester @Composable -internal fun rememberRequestingFocusRequester(): FocusRequester { +internal fun rememberRequestingFocusRequester( + focusKey: Any?, + isFocusOwner: Boolean, +): FocusRequester { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - runCatching { focusRequester.requestFocus() } + val latestIsFocusOwner by rememberUpdatedState(isFocusOwner) + LaunchedEffect(focusKey, isFocusOwner) { + if (isFocusOwner) { + focusRequester.requestWhenAttached( + isOwner = { latestIsFocusOwner }, + ) + } } return focusRequester } + +internal suspend fun FocusRequester.requestWhenAttached( + isOwner: () -> Boolean, + fallback: FocusRequester? = null, +): Boolean = requestFocusWithFallback( + isOwner = isOwner, + awaitNextAttempt = { withFrameNanos { } }, + requestPrimaryFocus = { runCatching { requestFocus() }.getOrDefault(false) }, + requestFallbackFocus = fallback?.let { fallbackRequester -> + { runCatching { fallbackRequester.requestFocus() }.getOrDefault(false) } + }, +) + +internal suspend fun requestFocusWithRetry( + isOwner: () -> Boolean, + awaitNextAttempt: suspend () -> Unit, + requestFocus: () -> Boolean, +): Boolean = requestFocusWithFallback( + isOwner = isOwner, + awaitNextAttempt = awaitNextAttempt, + requestPrimaryFocus = requestFocus, + requestFallbackFocus = null, +) + +internal suspend fun requestFocusWithFallback( + isOwner: () -> Boolean, + awaitNextAttempt: suspend () -> Unit, + requestPrimaryFocus: () -> Boolean, + requestFallbackFocus: (() -> Boolean)?, +): Boolean { + val primaryFocused = requestBoundedFocus(isOwner, awaitNextAttempt, requestPrimaryFocus) + val fallback = requestFallbackFocus + return when { + primaryFocused -> true + !isOwner() -> false + fallback == null -> false + else -> requestBoundedFocus(isOwner, awaitNextAttempt, fallback) + } +} + +private suspend fun requestBoundedFocus( + isOwner: () -> Boolean, + awaitNextAttempt: suspend () -> Unit, + requestFocus: () -> Boolean, +): Boolean { + repeat(FOCUS_REQUEST_ATTEMPTS) { + awaitNextAttempt() + if (!isOwner()) return false + if (requestFocus()) return true + } + return false +} + +private const val FOCUS_REQUEST_ATTEMPTS = 30 diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt index 82ce2189..d70449ef 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt @@ -1,17 +1,25 @@ package com.kino.puber.ui.feature.player.component +import android.view.KeyEvent import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.onKeyEvent import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.media3.exoplayer.ExoPlayer @@ -37,10 +45,33 @@ internal fun PlayerScreenContent( ) { val focusRequesters = rememberPlayerFocusRequesters() val contentState = (state as? PlayerViewState.Content)?.content + val focusOwner = contentState?.focusOwner() + var previousFocusOwner by remember { mutableStateOf(focusOwner) } + var controlsTransferInProgress by remember { mutableStateOf(false) } + val startsControlsTransfer = + previousFocusOwner == PlayerFocusOwner.Player && + focusOwner == PlayerFocusOwner.Controls + val retainPlayerAnchorFocus = + focusOwner == PlayerFocusOwner.Controls && + (startsControlsTransfer || controlsTransferInProgress) + val latestFocusOwner by rememberUpdatedState(focusOwner) + + SideEffect { + when { + startsControlsTransfer -> controlsTransferInProgress = true + focusOwner != PlayerFocusOwner.Controls -> controlsTransferInProgress = false + } + previousFocusOwner = focusOwner + } PlayerFocusEffects( content = contentState, focusRequesters = focusRequesters, + onControlsFocusEstablished = { + if (latestFocusOwner == PlayerFocusOwner.Controls) { + controlsTransferInProgress = false + } + }, ) Box( @@ -60,6 +91,7 @@ internal fun PlayerScreenContent( onAction = onAction, exoPlayer = exoPlayer, focusRequesters = focusRequesters, + retainPlayerAnchorFocus = retainPlayerAnchorFocus, ) } } @@ -69,35 +101,75 @@ internal fun PlayerScreenContent( private fun PlayerFocusEffects( content: PlayerContentState?, focusRequesters: PlayerFocusRequesters, + onControlsFocusEstablished: () -> Unit, ) { - val hasResumeDialog = content?.resumeDialog != null - LaunchedEffect(content?.controlsVisible, content?.activePanel, hasResumeDialog) { - if (hasResumeDialog) return@LaunchedEffect - if (content != null && !content.controlsVisible && content.activePanel == ActivePanel.None) { - runCatching { focusRequesters.player.requestFocus() } + val focusOwner = content?.focusOwner() + val controlsFocusTarget = content?.controlsFocusTarget ?: FocusTarget.Buttons + val latestContent by rememberUpdatedState(content) + val latestOnControlsFocusEstablished by rememberUpdatedState(onControlsFocusEstablished) + + LaunchedEffect(focusOwner) { + if (focusOwner == PlayerFocusOwner.Player) { + focusRequesters.player.requestWhenAttached( + isOwner = { + latestContent?.focusOwner() == PlayerFocusOwner.Player + }, + ) } } - LaunchedEffect(content?.controlsVisible, content?.controlsFocusTarget, hasResumeDialog) { - if (hasResumeDialog) return@LaunchedEffect - if (content?.controlsVisible == true && content.controlsFocusTarget != null) { - requestControlsFocus(content.controlsFocusTarget, focusRequesters) + LaunchedEffect(focusOwner, controlsFocusTarget) { + if (focusOwner == PlayerFocusOwner.Controls) { + val focusEstablished = requestControlsFocus( + target = controlsFocusTarget, + focusRequesters = focusRequesters, + isOwner = { + latestContent?.let { + it.focusOwner() == PlayerFocusOwner.Controls && + (it.controlsFocusTarget ?: FocusTarget.Buttons) == controlsFocusTarget + } == true + }, + ) + if (focusEstablished) { + latestOnControlsFocusEstablished() + } } } } -private fun requestControlsFocus( +private suspend fun requestControlsFocus( target: FocusTarget, focusRequesters: PlayerFocusRequesters, -) { - runCatching { - when (target) { - FocusTarget.SeekBar -> focusRequesters.seekBar.requestFocus() - FocusTarget.Buttons -> focusRequesters.firstButton.requestFocus() - FocusTarget.EpisodesButton -> focusRequesters.episodesButton.requestFocus() - FocusTarget.AudioSubtitlesButton -> focusRequesters.audioSubtitlesButton.requestFocus() - FocusTarget.VideoSettingsButton -> focusRequesters.videoSettingsButton.requestFocus() - } + isOwner: () -> Boolean, +): Boolean { + val (focusRequester, fallback) = when (target) { + FocusTarget.SeekBar -> focusRequesters.seekBar to focusRequesters.firstButton + FocusTarget.Buttons -> focusRequesters.firstButton to focusRequesters.seekBar + FocusTarget.EpisodesButton -> focusRequesters.episodesButton to focusRequesters.firstButton + FocusTarget.AudioSubtitlesButton -> + focusRequesters.audioSubtitlesButton to focusRequesters.firstButton + FocusTarget.VideoSettingsButton -> + focusRequesters.videoSettingsButton to focusRequesters.firstButton + } + return focusRequester.requestWhenAttached( + isOwner = isOwner, + fallback = fallback, + ) +} + +private enum class PlayerFocusOwner { + ResumeDialog, + Panel, + Controls, + Player, +} + +private fun PlayerContentState.focusOwner(): PlayerFocusOwner { + return when { + resumeDialog != null -> PlayerFocusOwner.ResumeDialog + activePanel != ActivePanel.None -> PlayerFocusOwner.Panel + controlsVisible -> PlayerFocusOwner.Controls + else -> PlayerFocusOwner.Player } } @@ -107,12 +179,17 @@ private fun PlayerContent( onAction: (UIAction) -> Unit, exoPlayer: () -> ExoPlayer?, focusRequesters: PlayerFocusRequesters, + retainPlayerAnchorFocus: Boolean, ) { PlayerVideoSurface( content = content, exoPlayer = exoPlayer, + ) + PlayerFocusAnchor( + content = content, onAction = onAction, focusRequester = focusRequesters.player, + retainFocus = retainPlayerAnchorFocus, ) PlaybackFeedbackLayer(content = content) BufferingProgressLayer(content = content) @@ -125,6 +202,84 @@ private fun PlayerContent( PlayerOverlayLayers(content = content, onAction = onAction) } +@Composable +private fun PlayerFocusAnchor( + content: PlayerContentState, + onAction: (UIAction) -> Unit, + focusRequester: FocusRequester, + retainFocus: Boolean, +) { + val isFocusOwner = + content.focusOwner() == PlayerFocusOwner.Player || retainFocus + Box( + modifier = Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable(enabled = isFocusOwner) + .then( + if (isFocusOwner) { + Modifier.onKeyEvent { keyEvent -> + handlePlayerKeyEvent( + keyEvent = keyEvent.nativeKeyEvent, + onAction = onAction, + ) + } + } else { + Modifier + }, + ), + ) +} + +private fun handlePlayerKeyEvent( + keyEvent: KeyEvent, + onAction: (UIAction) -> Unit, +): Boolean { + if (keyEvent.action != KeyEvent.ACTION_DOWN) return false + return when (keyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + onAction(PlayerAction.ShowControls(FocusTarget.SeekBar)) + true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + onAction(PlayerAction.ShowControls(FocusTarget.Buttons)) + true + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + onAction(PlayerAction.SeekBackward) + onAction(PlayerAction.ShowControls(FocusTarget.SeekBar)) + true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + onAction(PlayerAction.SeekForward) + onAction(PlayerAction.ShowControls(FocusTarget.SeekBar)) + true + } + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + onAction(PlayerAction.TogglePlayPause) + onAction(PlayerAction.ShowControls(FocusTarget.Buttons)) + true + } + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, + KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD -> { + onAction(PlayerAction.SeekForward) + onAction(PlayerAction.ShowControls(FocusTarget.SeekBar)) + true + } + KeyEvent.KEYCODE_MEDIA_REWIND, + KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD -> { + onAction(PlayerAction.SeekBackward) + onAction(PlayerAction.ShowControls(FocusTarget.SeekBar)) + true + } + else -> false + } +} + @Composable private fun ErrorOverlay( message: String, diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerSettingsPanels.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerSettingsPanels.kt index a0a04f30..bad086f9 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerSettingsPanels.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerSettingsPanels.kt @@ -21,6 +21,8 @@ internal fun PlayerSettingsPanels( AudioSubtitlesPanel( visible = content.activePanel == ActivePanel.AudioSubtitles, + isFocusOwner = content.resumeDialog == null && + content.activePanel == ActivePanel.AudioSubtitles, soundModes = content.soundModes, selectedSoundModeIndex = content.selectedSoundModeIndex, audioTracks = content.audioTracks, @@ -36,6 +38,8 @@ internal fun PlayerSettingsPanels( VideoSettingsPanel( visible = content.activePanel == ActivePanel.VideoSettings, + isFocusOwner = content.resumeDialog == null && + content.activePanel == ActivePanel.VideoSettings, qualities = content.qualities, selectedQualityIndex = content.selectedQualityIndex, speeds = content.speeds, @@ -56,6 +60,7 @@ internal fun PlayerSettingsPanels( EpisodesPanel( visible = content.activePanel == ActivePanel.Episodes, episodes = content.episodes, + initialFocusedItemId = content.currentEpisodeId, onEpisodeSelected = { item -> onAction(PlayerAction.SelectEpisodeById(item.id)) }, onEpisodeContextMenu = { episodeContextMenuItem = it }, onBackPressed = rememberAction(onAction, PlayerAction.ClosePanel), diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerVideoSurface.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerVideoSurface.kt index a60dc17f..fbf106fe 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerVideoSurface.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerVideoSurface.kt @@ -3,35 +3,25 @@ package com.kino.puber.ui.feature.player.component import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.view.KeyEvent import android.view.View import android.view.Window import android.view.WindowManager -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView -import com.kino.puber.core.ui.uikit.model.UIAction import com.kino.puber.ui.feature.player.model.AspectRatioMode -import com.kino.puber.ui.feature.player.model.FocusTarget -import com.kino.puber.ui.feature.player.model.PlayerAction import com.kino.puber.ui.feature.player.model.PlayerContentState @Composable internal fun PlayerVideoSurface( content: PlayerContentState, exoPlayer: () -> ExoPlayer?, - onAction: (UIAction) -> Unit, - focusRequester: FocusRequester, playerViewFactory: (Context) -> PlayerView = { context -> PlayerView(context) }, ) { val windowKeepScreenOnBinding = remember { WindowKeepScreenOnBinding() } @@ -64,17 +54,7 @@ internal fun PlayerVideoSurface( windowKeepScreenOnBinding.release(view) view.player = null }, - modifier = Modifier - .fillMaxSize() - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { keyEvent -> - handlePlayerKeyEvent( - keyEvent = keyEvent.nativeKeyEvent, - hasResumeDialog = content.resumeDialog != null, - onAction = onAction, - ) - }, + modifier = Modifier.fillMaxSize(), ) } @@ -159,32 +139,6 @@ private tailrec fun Context.findActivity(): Activity? { } } -private fun handlePlayerKeyEvent( - keyEvent: KeyEvent, - hasResumeDialog: Boolean, - onAction: (UIAction) -> Unit, -): Boolean { - if (keyEvent.action != KeyEvent.ACTION_DOWN || hasResumeDialog) return false - val action = when (keyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT -> PlayerAction.SeekBackward - KeyEvent.KEYCODE_DPAD_RIGHT -> PlayerAction.SeekForward - KeyEvent.KEYCODE_DPAD_UP -> PlayerAction.ShowControls(FocusTarget.SeekBar) - KeyEvent.KEYCODE_DPAD_DOWN -> PlayerAction.ShowControls(FocusTarget.Buttons) - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - KeyEvent.KEYCODE_MEDIA_PLAY, - KeyEvent.KEYCODE_MEDIA_PAUSE -> PlayerAction.TogglePlayPause - KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, - KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD -> PlayerAction.SeekForward - KeyEvent.KEYCODE_MEDIA_REWIND, - KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD -> PlayerAction.SeekBackward - else -> null - } - action?.let(onAction) - return action != null -} - @androidx.annotation.OptIn(UnstableApi::class) private fun PlayerContentState.resizeMode(): Int { return when (aspectRatios.getOrNull(selectedAspectRatioIndex)?.mode) { diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt index 73744370..e2b06e3f 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt @@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,8 +39,10 @@ internal fun ResumeDialog( enter = fadeIn(), exit = fadeOut(), ) { - val resumeButtonFocusRequester = remember { FocusRequester() } - RequestResumeButtonFocus(resumeButtonFocusRequester) + val resumeButtonFocusRequester = rememberRequestingFocusRequester( + focusKey = state, + isFocusOwner = state != null, + ) ResumeDialogScrim { state?.let { @@ -57,16 +57,6 @@ internal fun ResumeDialog( } } -@Composable -private fun RequestResumeButtonFocus(resumeButtonFocusRequester: FocusRequester) { - LaunchedEffect(Unit) { - try { - resumeButtonFocusRequester.requestFocus() - } catch (_: Exception) { - } - } -} - @Composable private fun ResumeDialogScrim(content: @Composable () -> Unit) { Box( diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt index a5f30a68..423f1cda 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -40,6 +41,7 @@ import com.kino.puber.ui.feature.player.model.SpeedUIState @Composable internal fun VideoSettingsPanel( visible: Boolean, + isFocusOwner: Boolean, qualities: List, selectedQualityIndex: Int, speeds: List, @@ -63,6 +65,18 @@ internal fun VideoSettingsPanel( enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }), ) { + val initialFocusTarget = when { + qualities.isNotEmpty() -> VideoSettingsFocusTarget.Quality + speeds.isNotEmpty() -> VideoSettingsFocusTarget.Speed + aspectRatios.isNotEmpty() -> VideoSettingsFocusTarget.AspectRatio + bufferPresets.isNotEmpty() -> VideoSettingsFocusTarget.Buffer + else -> VideoSettingsFocusTarget.FastDns + } + val panelFocusRequester = rememberRequestingFocusRequester( + focusKey = initialFocusTarget, + isFocusOwner = isFocusOwner, + ) + VideoSettingsPanelContainer { VideoSettingsColumns( state = VideoSettingsPanelState( @@ -75,7 +89,8 @@ internal fun VideoSettingsPanel( bufferPresets = bufferPresets, selectedBufferPresetIndex = selectedBufferPresetIndex, ), - panelFocusRequester = rememberRequestingFocusRequester(), + panelFocusRequester = panelFocusRequester, + initialFocusTarget = initialFocusTarget, actions = VideoSettingsActions( onQualitySelected = onQualitySelected, onSpeedSelected = onSpeedSelected, @@ -83,11 +98,25 @@ internal fun VideoSettingsPanel( onBufferPresetSelected = onBufferPresetSelected, ), ) - FastDnsToggle(checked = fastDnsEnabled, onToggle = onToggleFastDns) + FastDnsToggle( + checked = fastDnsEnabled, + onToggle = onToggleFastDns, + focusRequester = panelFocusRequester.takeIf { + initialFocusTarget == VideoSettingsFocusTarget.FastDns + }, + ) } } } +private enum class VideoSettingsFocusTarget { + Quality, + Speed, + AspectRatio, + Buffer, + FastDns, +} + private data class VideoSettingsPanelState( val qualities: List, val selectedQualityIndex: Int, @@ -128,6 +157,7 @@ private fun VideoSettingsPanelContainer(content: @Composable ColumnScope.() -> U private fun ColumnScope.VideoSettingsColumns( state: VideoSettingsPanelState, panelFocusRequester: FocusRequester, + initialFocusTarget: VideoSettingsFocusTarget, actions: VideoSettingsActions, ) { Row( @@ -137,13 +167,22 @@ private fun ColumnScope.VideoSettingsColumns( .padding(top = 24.dp, bottom = 8.dp), horizontalArrangement = Arrangement.SpaceEvenly, ) { - QualityColumn(state, panelFocusRequester, actions.onQualitySelected) + QualityColumn( + state = state, + panelFocusRequester = panelFocusRequester.takeIf { + initialFocusTarget == VideoSettingsFocusTarget.Quality + }, + onQualitySelected = actions.onQualitySelected, + ) SettingsPanelColumn( header = stringResource(R.string.player_panel_speed), items = remember(state.speeds) { state.speeds.map { it.label } }, selectedIndex = state.selectedSpeedIndex, onItemSelected = actions.onSpeedSelected, modifier = Modifier.weight(1f), + firstItemFocusRequester = panelFocusRequester.takeIf { + initialFocusTarget == VideoSettingsFocusTarget.Speed + }, ) SettingsPanelColumn( header = stringResource(R.string.player_panel_aspect_ratio), @@ -151,6 +190,9 @@ private fun ColumnScope.VideoSettingsColumns( selectedIndex = state.selectedAspectRatioIndex, onItemSelected = actions.onAspectRatioSelected, modifier = Modifier.weight(1f), + firstItemFocusRequester = panelFocusRequester.takeIf { + initialFocusTarget == VideoSettingsFocusTarget.AspectRatio + }, ) SettingsPanelColumn( header = stringResource(R.string.player_panel_buffer), @@ -158,6 +200,9 @@ private fun ColumnScope.VideoSettingsColumns( selectedIndex = state.selectedBufferPresetIndex, onItemSelected = actions.onBufferPresetSelected, modifier = Modifier.weight(1f), + firstItemFocusRequester = panelFocusRequester.takeIf { + initialFocusTarget == VideoSettingsFocusTarget.Buffer + }, ) } } @@ -165,7 +210,7 @@ private fun ColumnScope.VideoSettingsColumns( @Composable private fun RowScope.QualityColumn( state: VideoSettingsPanelState, - panelFocusRequester: FocusRequester, + panelFocusRequester: FocusRequester?, onQualitySelected: (Int) -> Unit, ) { if (state.qualities.isEmpty()) return @@ -183,12 +228,14 @@ private fun RowScope.QualityColumn( private fun FastDnsToggle( checked: Boolean, onToggle: () -> Unit, + focusRequester: FocusRequester?, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() Row( modifier = Modifier + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) .highlightOnFocus(isFocused) .clickable( interactionSource = interactionSource, diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt index 5e3a6e53..2edc69db 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt @@ -25,9 +25,27 @@ internal class ControlsStateMachine { private var lastPanelOpener: FocusTarget = FocusTarget.Buttons private var wasPlayRequestedBeforePanel = false + fun initialize(resumeDialogVisible: Boolean) { + state = State( + controlsVisible = !resumeDialogVisible, + focusTarget = FocusTarget.Buttons.takeUnless { resumeDialogVisible }, + activePanel = ActivePanel.None, + ) + lastPanelOpener = FocusTarget.Buttons + wasPlayRequestedBeforePanel = false + } + fun showControls(focusTarget: FocusTarget): List { + return revealControls(focusTarget, Effect.ScheduleHide) + } + + fun showControlsPersistently(focusTarget: FocusTarget): List { + return revealControls(focusTarget, Effect.CancelHide) + } + + private fun revealControls(focusTarget: FocusTarget, effect: Effect): List { state = state.copy(controlsVisible = true, focusTarget = focusTarget) - return listOf(Effect.ScheduleHide) + return listOf(effect) } fun hideControls(): List { diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt index 47f3cf41..098b1f22 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt @@ -289,7 +289,7 @@ internal class PlayerVM( isMarkCurrentWatchedInFlight = watchedMutationsInFlight[token.key].orZero() > 0, ) - updateViewState(PlayerViewState.Content(contentState)) + publishPreparedContent(contentState, resumeDialog) autoMarkHandledToken = token.takeIf { resolved.isCurrentMediaWatched } episodeSwitchInProgress = false initializePlayer(savedPosition = if (resumeDialog != null) null else 0L) @@ -298,6 +298,19 @@ internal class PlayerVM( loadSkipSegments(item, resolved.seasonNumber, resolved.episodeNumber, token) } + private fun publishPreparedContent( + contentState: PlayerContentState, + resumeDialog: ResumeDialogState?, + ) { + controlsHideJob?.cancel() + controlsStateMachine.initialize(resumeDialogVisible = resumeDialog != null) + updateViewState( + PlayerViewState.Content( + contentState.withControlsState(controlsStateMachine.state), + ), + ) + } + private fun isCurrentPrepare(generation: Long): Boolean { return !closing && generation == mediaGeneration } @@ -481,6 +494,12 @@ internal class PlayerVM( processEffects(effects) } + private fun showControlsPersistently(focusTarget: FocusTarget) { + val effects = controlsStateMachine.showControlsPersistently(focusTarget) + applyControlsState() + processEffects(effects) + } + private fun hideControls() { val effects = controlsStateMachine.hideControls() applyControlsState() @@ -511,11 +530,7 @@ internal class PlayerVM( private fun applyControlsState() { val cs = controlsStateMachine.state updateContent { - copy( - controlsVisible = cs.controlsVisible, - controlsFocusTarget = cs.focusTarget, - activePanel = cs.activePanel, - ) + withControlsState(cs) } } @@ -984,7 +999,7 @@ internal class PlayerVM( !content.isMovie && content.hasNextEpisode && content.nextEpisodeCountdown == null -> startNextEpisodeCountdown() - !content.isMovie -> updateContent { copy(controlsVisible = true) } + !content.isMovie -> showControlsPersistently(FocusTarget.Buttons) } } @@ -1033,6 +1048,16 @@ internal class PlayerVM( scheduleControlsHide() } + private fun PlayerContentState.withControlsState( + state: ControlsStateMachine.State, + ): PlayerContentState { + return copy( + controlsVisible = state.controlsVisible, + controlsFocusTarget = state.focusTarget, + activePanel = state.activePanel, + ) + } + private fun pauseForBackground() { val wasPlayRequested = playbackController.playbackIntent == PlaybackIntent.PlayRequested if (wasPlayRequested) { diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/component/PanelFocusRequesterTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/component/PanelFocusRequesterTest.kt new file mode 100644 index 00000000..0dc38e94 --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/component/PanelFocusRequesterTest.kt @@ -0,0 +1,155 @@ +package com.kino.puber.ui.feature.player.component + +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class PanelFocusRequesterTest { + + @Test + fun requestFocusWithRetry_doesNotRequest_whenOwnerEndsBeforeFirstAttempt() = runTest { + var isOwner = true + var requestCount = 0 + + val focused = requestFocusWithRetry( + isOwner = { isOwner }, + awaitNextAttempt = { isOwner = false }, + requestFocus = { + requestCount++ + true + }, + ) + + assertFalse(focused) + assertEquals(0, requestCount) + } + + @Test + fun requestFocusWithRetry_stopsRetrying_whenOwnerChangesDuringExit() = runTest { + var isOwner = true + var requestCount = 0 + + val focused = requestFocusWithRetry( + isOwner = { isOwner }, + awaitNextAttempt = {}, + requestFocus = { + requestCount++ + isOwner = false + false + }, + ) + + assertFalse(focused) + assertEquals(1, requestCount) + } + + @Test + fun requestFocusWithRetry_stopsWithoutRequest_whenDisposedCoroutineIsCancelled() = runTest { + var requestCount = 0 + val requestJob = launch { + requestFocusWithRetry( + isOwner = { true }, + awaitNextAttempt = { awaitCancellation() }, + requestFocus = { + requestCount++ + true + }, + ) + } + + runCurrent() + requestJob.cancelAndJoin() + + assertEquals(0, requestCount) + } + + @Test + fun requestFocusWithRetry_isBounded_whenTargetIsDisabledOrDetached() = runTest { + var frameCount = 0 + var requestCount = 0 + + val focused = requestFocusWithRetry( + isOwner = { true }, + awaitNextAttempt = { frameCount++ }, + requestFocus = { + requestCount++ + false + }, + ) + + assertFalse(focused) + assertEquals(30, frameCount) + assertEquals(30, requestCount) + } + + @Test + fun requestFocusWithRetry_succeeds_whenOwnedTargetAttaches() = runTest { + var requestCount = 0 + + val focused = requestFocusWithRetry( + isOwner = { true }, + awaitNextAttempt = {}, + requestFocus = { + requestCount++ + requestCount == 3 + }, + ) + + assertTrue(focused) + assertEquals(3, requestCount) + } + + @Test + fun requestWhenAttached_usesFallback_afterPrimaryBoundExpires() = runTest { + var primaryRequestCount = 0 + var fallbackRequestCount = 0 + + val focused = requestFocusWithFallback( + isOwner = { true }, + awaitNextAttempt = {}, + requestPrimaryFocus = { + primaryRequestCount++ + false + }, + requestFallbackFocus = { + fallbackRequestCount++ + true + }, + ) + + assertTrue(focused) + assertEquals(30, primaryRequestCount) + assertEquals(1, fallbackRequestCount) + } + + @Test + fun requestWhenAttached_doesNotUseFallback_afterOwnershipEnds() = runTest { + var isOwner = true + var primaryRequestCount = 0 + var fallbackRequestCount = 0 + + val focused = requestFocusWithFallback( + isOwner = { isOwner }, + awaitNextAttempt = {}, + requestPrimaryFocus = { + primaryRequestCount++ + isOwner = false + false + }, + requestFallbackFocus = { + fallbackRequestCount++ + true + }, + ) + + assertFalse(focused) + assertEquals(1, primaryRequestCount) + assertEquals(0, fallbackRequestCount) + } +} diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/ControlsStateMachineTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/ControlsStateMachineTest.kt index a648d078..4e31afb4 100644 --- a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/ControlsStateMachineTest.kt +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/ControlsStateMachineTest.kt @@ -17,6 +17,28 @@ class ControlsStateMachineTest { machine = ControlsStateMachine() } + // ------------------------------------------------------------------ + // initialize + // ------------------------------------------------------------------ + + @Test + fun `initialize_withoutResumeDialog_showsInitialButtons`() { + machine.initialize(resumeDialogVisible = false) + + assertTrue(machine.state.controlsVisible) + assertEquals(FocusTarget.Buttons, machine.state.focusTarget) + assertEquals(ActivePanel.None, machine.state.activePanel) + } + + @Test + fun `initialize_withResumeDialog_keepsControlsHidden`() { + machine.initialize(resumeDialogVisible = true) + + assertFalse(machine.state.controlsVisible) + assertEquals(null, machine.state.focusTarget) + assertEquals(ActivePanel.None, machine.state.activePanel) + } + // ------------------------------------------------------------------ // showControls // ------------------------------------------------------------------ @@ -36,6 +58,15 @@ class ControlsStateMachineTest { assertEquals(FocusTarget.SeekBar, machine.state.focusTarget) } + @Test + fun `showControlsPersistently_setsTargetAndCancelsOrdinaryHide`() { + val effects = machine.showControlsPersistently(FocusTarget.Buttons) + + assertTrue(machine.state.controlsVisible) + assertEquals(FocusTarget.Buttons, machine.state.focusTarget) + assertEquals(listOf(ControlsStateMachine.Effect.CancelHide), effects) + } + // ------------------------------------------------------------------ // hideControls // ------------------------------------------------------------------ @@ -117,6 +148,24 @@ class ControlsStateMachineTest { assertEquals(FocusTarget.EpisodesButton, machine.state.focusTarget) } + @Test + fun `closePanel_restoresAudioSubtitlesOpener`() { + machine.openPanel(ActivePanel.AudioSubtitles, playbackIntent = PlaybackIntent.Paused) + + machine.closePanel() + + assertEquals(FocusTarget.AudioSubtitlesButton, machine.state.focusTarget) + } + + @Test + fun `closePanel_restoresVideoSettingsOpener`() { + machine.openPanel(ActivePanel.VideoSettings, playbackIntent = PlaybackIntent.Paused) + + machine.closePanel() + + assertEquals(FocusTarget.VideoSettingsButton, machine.state.focusTarget) + } + // ------------------------------------------------------------------ // handleBack // ------------------------------------------------------------------ diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMFocusInitializationTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMFocusInitializationTest.kt new file mode 100644 index 00000000..ff4a5981 --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMFocusInitializationTest.kt @@ -0,0 +1,95 @@ +package com.kino.puber.ui.feature.player.vm + +import com.kino.puber.ui.feature.player.model.FocusTarget +import com.kino.puber.ui.feature.player.model.PlayerAction +import com.kino.puber.ui.feature.player.model.ResumeDialogState +import com.kino.puber.util.MainDispatcherExtension +import io.mockk.coEvery +import io.mockk.every +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +internal class PlayerVMFocusInitializationTest : PlayerVMTestFixture() { + + companion object { + @JvmField + @RegisterExtension + val mainDispatcher = MainDispatcherExtension() + } + + @Test + fun onStart_normalEntryPublishesInitialControlsFocus() { + val vm = startedVM() + + assertTrue(contentState(vm).controlsVisible) + assertEquals(FocusTarget.Buttons, contentState(vm).controlsFocusTarget) + + vm.onAction(PlayerAction.OnBackPressed) + + assertFalse(contentState(vm).controlsVisible) + verify(exactly = 0) { router.back(any(), any()) } + } + + @Test + fun onStart_resumeDialogKeepsControlsHidden_untilExplicitReveal() { + every { + interactor.resolveMedia(any(), any(), any(), any()) + } returns testResolvedMedia.copy(watchingTime = 120) + coEvery { + contentStateFactory.build(any(), any(), any(), any(), any(), any()) + } returns testContentState.copy( + controlsVisible = true, + controlsFocusTarget = FocusTarget.Buttons, + resumeDialog = ResumeDialogState( + savedPosition = 120_000L, + formattedTime = "2:00", + episodeInfo = "S1E1", + ), + ) + val vm = startedVM() + + assertFalse(contentState(vm).controlsVisible) + assertEquals(null, contentState(vm).controlsFocusTarget) + + vm.onAction(PlayerAction.ResumeFromPosition) + + assertFalse(contentState(vm).controlsVisible) + assertEquals(null, contentState(vm).controlsFocusTarget) + } + + @Test + fun resumeFromPosition_seeksToSavedPosition_clearsDialog() { + coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy( + resumeDialog = ResumeDialogState(savedPosition = 120_000L, formattedTime = "2:00", episodeInfo = null), + isPlaying = false, + ) + val vm = startedVM() + + vm.onAction(PlayerAction.ResumeFromPosition) + + verify { playbackController.seekTo(120_000L) } + verify { playbackController.play() } + assertNull(contentState(vm).resumeDialog) + assertEquals(PlaybackIntent.PlayRequested, contentState(vm).playbackIntent) + } + + @Test + fun startFromBeginning_seeksToZero_clearsDialog() { + coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy( + resumeDialog = ResumeDialogState(savedPosition = 120_000L, formattedTime = "2:00", episodeInfo = null), + isPlaying = false, + ) + val vm = startedVM() + + vm.onAction(PlayerAction.StartFromBeginning) + + verify { playbackController.seekTo(0) } + verify { playbackController.play() } + assertNull(contentState(vm).resumeDialog) + } +} diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMMutationAndResultTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMMutationAndResultTest.kt index 6ace6504..3a433749 100644 --- a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMMutationAndResultTest.kt +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMMutationAndResultTest.kt @@ -8,6 +8,7 @@ import com.kino.puber.data.api.models.ItemType import com.kino.puber.data.api.models.SkipSegmentType import com.kino.puber.data.api.models.Video import com.kino.puber.domain.interactor.player.WatchedDetailsRefreshException +import com.kino.puber.ui.feature.player.model.FocusTarget import com.kino.puber.ui.feature.player.model.PlayerAction import com.kino.puber.ui.feature.player.model.SkipSegmentUIState import com.kino.puber.util.MainDispatcherExtension @@ -217,6 +218,7 @@ internal class PlayerVMMutationAndResultTest : PlayerVMTestFixture() { assertTrue(contentState(vm).isCurrentMediaWatched) verify(exactly = 1) { errorHandler.map(refreshFailure) } vm.onAction(PlayerAction.OnBackPressed) + vm.onAction(PlayerAction.OnBackPressed) verifyContentChangeResult(ContentChangeType.Watched, ContentChangeType.PlaybackProgress) } @@ -324,6 +326,7 @@ internal class PlayerVMMutationAndResultTest : PlayerVMTestFixture() { vm.onAction(PlayerAction.EpisodeWatchedChanged(currentEpisodeItem, watched = true)) vm.onAction(PlayerAction.OnBackPressed) + vm.onAction(PlayerAction.OnBackPressed) assertTrue(contentState(vm).isCurrentMediaWatched) verifyContentChangeResult(ContentChangeType.Watched, ContentChangeType.PlaybackProgress) @@ -414,10 +417,22 @@ internal class PlayerVMMutationAndResultTest : PlayerVMTestFixture() { ) val vm = startedVM() + mainDispatcher.dispatcher.scheduler.advanceTimeBy(3_001L) + assertFalse(contentState(vm).controlsVisible) + callbackSlot.captured.onPlaybackEnded() assertNull(contentState(vm).nextEpisodeCountdown) assertTrue(contentState(vm).controlsVisible) + assertEquals(FocusTarget.Buttons, contentState(vm).controlsFocusTarget) + + mainDispatcher.dispatcher.scheduler.advanceTimeBy(3_001L) + + assertTrue(contentState(vm).controlsVisible) + assertEquals(FocusTarget.Buttons, contentState(vm).controlsFocusTarget) + vm.onAction(PlayerAction.OnBackPressed) + assertFalse(contentState(vm).controlsVisible) + verify(exactly = 0) { router.back(any(), any()) } } @Test @@ -429,6 +444,7 @@ internal class PlayerVMMutationAndResultTest : PlayerVMTestFixture() { callbackSlot.captured.onPlaybackEnded() vm.onAction(PlayerAction.OnBackPressed) + vm.onAction(PlayerAction.OnBackPressed) verifyContentChangeResult(ContentChangeType.Watched, ContentChangeType.PlaybackProgress) } @@ -457,6 +473,7 @@ internal class PlayerVMMutationAndResultTest : PlayerVMTestFixture() { ) ) vm.onAction(PlayerAction.OnBackPressed) + vm.onAction(PlayerAction.OnBackPressed) verifyContentChangeResult(ContentChangeType.Watched, ContentChangeType.PlaybackProgress) } @@ -480,6 +497,7 @@ internal class PlayerVMMutationAndResultTest : PlayerVMTestFixture() { ) ) vm.onAction(PlayerAction.OnBackPressed) + vm.onAction(PlayerAction.OnBackPressed) verifyContentChangeResult(ContentChangeType.Watched, ContentChangeType.PlaybackProgress) } diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt index c0b101b4..c7f2bdc4 100644 --- a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt @@ -16,7 +16,6 @@ import com.kino.puber.ui.feature.player.model.AudioTrackUIState import com.kino.puber.ui.feature.player.model.PlayerAction import com.kino.puber.ui.feature.player.model.PlayerScreenParams import com.kino.puber.ui.feature.player.model.PlayerViewState -import com.kino.puber.ui.feature.player.model.ResumeDialogState import com.kino.puber.ui.feature.player.model.SkipSegmentUIState import com.kino.puber.util.FakeResourceProvider import com.kino.puber.util.MainDispatcherExtension @@ -355,6 +354,7 @@ internal class PlayerVMTest : PlayerVMTestFixture() { fun backPressed_afterProgressSave_returnsPlaybackProgressResult() { val vm = startedVM() + vm.onAction(PlayerAction.OnBackPressed) vm.onAction(PlayerAction.OnBackPressed) verifyContentChangeResult(ContentChangeType.PlaybackProgress) @@ -368,6 +368,7 @@ internal class PlayerVMTest : PlayerVMTestFixture() { } val vm = startedVM() + vm.onAction(PlayerAction.OnBackPressed) vm.onAction(PlayerAction.OnBackPressed) verify(exactly = 0) { router.back(any(), any()) } @@ -405,6 +406,7 @@ internal class PlayerVMTest : PlayerVMTestFixture() { coEvery { interactor.saveWatchingTime(42, 1, 0, 1) } throws IllegalStateException("save failed") val vm = startedVM() + vm.onAction(PlayerAction.OnBackPressed) vm.onAction(PlayerAction.OnBackPressed) verifyEmptyContentChangeResult() @@ -425,6 +427,7 @@ internal class PlayerVMTest : PlayerVMTestFixture() { Timber.plant(logTree) try { vm.onAction(PlayerAction.OnBackPressed) + vm.onAction(PlayerAction.OnBackPressed) } finally { Timber.uproot(logTree) } @@ -692,41 +695,6 @@ internal class PlayerVMTest : PlayerVMTestFixture() { // endregion - // region Resume dialog - - @Test - fun resumeFromPosition_seeksToSavedPosition_clearsDialog() { - coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy( - resumeDialog = ResumeDialogState(savedPosition = 120_000L, formattedTime = "2:00", episodeInfo = null), - isPlaying = false, - ) - val vm = startedVM() - - vm.onAction(PlayerAction.ResumeFromPosition) - - verify { playbackController.seekTo(120_000L) } - verify { playbackController.play() } - assertNull(contentState(vm).resumeDialog) - assertEquals(PlaybackIntent.PlayRequested, contentState(vm).playbackIntent) - } - - @Test - fun startFromBeginning_seeksToZero_clearsDialog() { - coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy( - resumeDialog = ResumeDialogState(savedPosition = 120_000L, formattedTime = "2:00", episodeInfo = null), - isPlaying = false, - ) - val vm = startedVM() - - vm.onAction(PlayerAction.StartFromBeginning) - - verify { playbackController.seekTo(0) } - verify { playbackController.play() } - assertNull(contentState(vm).resumeDialog) - } - - // endregion - // region Subtitle selection @Test