diff --git a/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/EnhancedVideoPlayerTest.kt b/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/EnhancedVideoPlayerTest.kt index de3d7491..8e41ccf5 100644 --- a/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/EnhancedVideoPlayerTest.kt +++ b/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/EnhancedVideoPlayerTest.kt @@ -1,9 +1,14 @@ package com.profusion.androidenhancedvideoplayer.test import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.doubleClick import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.printToLog import com.profusion.androidenhancedvideoplayer.components.EnhancedVideoPlayer import org.junit.Rule import org.junit.Test @@ -19,14 +24,34 @@ class EnhancedVideoPlayerTest { resourceId = R.raw.login_screen_background ) } - composeTestRule.onNodeWithTag("PlayerControlsParent").assertDoesNotExist() composeTestRule.onNodeWithTag("VideoPlayerParent") .assertIsDisplayed() - .performClick() + + composeTestRule.onAllNodesWithTag("SeekClickableArea", useUnmergedTree = true)[0] + .performTouchInput { + click() + } composeTestRule.onNodeWithTag("PlayerControlsParent", useUnmergedTree = true) .assertIsDisplayed() } + + @Test + fun enhancedVideoPlayer_WhenDoubleClickHappenOnTheFirstHalfOfScreenVideoShouldShowRewindIcon() { + composeTestRule.setContent { + EnhancedVideoPlayer( + resourceId = R.raw.login_screen_background + ) + } + + composeTestRule.onAllNodesWithTag("SeekClickableArea", useUnmergedTree = true)[0] + .performTouchInput { + doubleClick() + } + + composeTestRule.onNodeWithTag("RewindIcon", useUnmergedTree = true) + .assertIsDisplayed() + } } diff --git a/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/PlayerControlsTest.kt b/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/PlayerControlsTest.kt index aca0606e..0a8c3e84 100644 --- a/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/PlayerControlsTest.kt +++ b/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/PlayerControlsTest.kt @@ -148,7 +148,7 @@ class PlayerControlsTest { @Test fun playerControls_WhenPressedSettingsButtonShouldShowSettings() { composeTestRule.setContent { - defaultPlayerControls() + DefaultPlayerControls() } composeTestRule.onNodeWithTag("SettingsControlsParent").assertDoesNotExist() diff --git a/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/SettingsTest.kt b/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/SettingsTest.kt index fae2e7c2..519378a4 100644 --- a/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/SettingsTest.kt +++ b/androidenhancedvideoplayer/src/androidTest/java/com/profusion/androidenhancedvideoplayer/test/SettingsTest.kt @@ -1,10 +1,10 @@ package com.profusion.androidenhancedvideoplayer.test +import DefaultPlayerControls import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import defaultPlayerControls import org.junit.Rule import org.junit.Test @@ -15,7 +15,7 @@ class SettingsTest { @Test fun settings_WhenClickingOnSpeedButtonShouldShowSpeedSelector() { composeTestRule.setContent { - defaultPlayerControls() + DefaultPlayerControls() } composeTestRule.onNodeWithTag("Playback SpeedSettingsSelector").assertDoesNotExist() diff --git a/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/EnhancedVideoPlayer.kt b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/EnhancedVideoPlayer.kt index d18b9ef3..8a77dae9 100644 --- a/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/EnhancedVideoPlayer.kt +++ b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/EnhancedVideoPlayer.kt @@ -2,9 +2,13 @@ package com.profusion.androidenhancedvideoplayer.components import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.net.Uri -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.animation.* // ktlint-disable no-wildcard-imports +import androidx.compose.animation.core.* // ktlint-disable no-wildcard-imports +import androidx.compose.foundation.* // ktlint-disable no-wildcard-imports +import androidx.compose.foundation.layout.* // ktlint-disable no-wildcard-imports import androidx.compose.foundation.layout.Box +import androidx.compose.material.* // ktlint-disable no-wildcard-imports +import androidx.compose.runtime.* // ktlint-disable no-wildcard-imports import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -12,9 +16,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.* // ktlint-disable no-wildcard-imports +import androidx.compose.ui.graphics.* // ktlint-disable no-wildcard-imports +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.* // ktlint-disable no-wildcard-imports import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackParameters @@ -25,12 +33,19 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import com.profusion.androidenhancedvideoplayer.components.playerOverlay.ControlsCustomization import com.profusion.androidenhancedvideoplayer.components.playerOverlay.PlayerControls +import com.profusion.androidenhancedvideoplayer.components.playerOverlay.SeekClickableArea import com.profusion.androidenhancedvideoplayer.components.playerOverlay.SettingsControlsCustomization +import com.profusion.androidenhancedvideoplayer.repository.VideoPlayerRepository +import com.profusion.androidenhancedvideoplayer.utils.executeAfterTimeout import com.profusion.androidenhancedvideoplayer.utils.setLandscape import com.profusion.androidenhancedvideoplayer.utils.setPortrait +import kotlin.math.max +import kotlin.math.min private const val MAIN_PACKAGE_PATH_PREFIX = "android.resource://" +private const val DEFAULT_SEEK_TIME_MS = 10 * 1000L // 10 seconds + @androidx.annotation.OptIn(UnstableApi::class) @Composable fun EnhancedVideoPlayer( @@ -40,10 +55,13 @@ fun EnhancedVideoPlayer( playImmediately: Boolean = true, soundOff: Boolean = true, controlsCustomization: ControlsCustomization = ControlsCustomization(), - settingsControlsCustomization: SettingsControlsCustomization = SettingsControlsCustomization() + settingsControlsCustomization: SettingsControlsCustomization = SettingsControlsCustomization(), + transformSeekIncrementRatio: (tapCount: Int) -> Long = { it -> it * DEFAULT_SEEK_TIME_MS } ) { val context = LocalContext.current val configuration = LocalConfiguration.current + val scope = rememberCoroutineScope() + val repository = VideoPlayerRepository val mainPackagePath = "$MAIN_PACKAGE_PATH_PREFIX${context.packageName}/" val exoPlayer = remember { @@ -60,7 +78,10 @@ fun EnhancedVideoPlayer( prepare() } } - + var rewindState by remember { mutableStateOf(false) } + var forwardState by remember { mutableStateOf(false) } + var forwardTapCount by remember { mutableStateOf(0) } + var rewindTapCount by remember { mutableStateOf(0) } var isPlaying by remember { mutableStateOf(exoPlayer.isPlaying) } var hasEnded by remember { mutableStateOf(exoPlayer.playbackState == ExoPlayer.STATE_ENDED) } var isControlsVisible by remember { mutableStateOf(false) } @@ -71,6 +92,76 @@ fun EnhancedVideoPlayer( mutableStateOf(exoPlayer.currentMediaItem?.mediaMetadata?.displayTitle?.toString()) } + val transition = rememberInfiniteTransition() + + val scale = transition.animateFloat( + initialValue = 0.8f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 650, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ) + ) + + LaunchedEffect(forwardTapCount) { + val timeToSeek = exoPlayer.currentPosition + + transformSeekIncrementRatio(forwardTapCount) + exoPlayer.seekTo(min(exoPlayer.duration, timeToSeek)) + } + + LaunchedEffect(rewindTapCount) { + val currentPosition = exoPlayer.currentPosition + val timeToSeek = currentPosition - transformSeekIncrementRatio(rewindTapCount) + exoPlayer.seekTo(max(0, timeToSeek)) + } + + fun toggleIsControlsVisible() { + if (!rewindState && !forwardState) { + isControlsVisible = !isControlsVisible + } + } + + fun setRewindState(isSeekEnabled: Boolean) { + rewindState = isSeekEnabled + } + + fun setForwardState(isSeekEnabled: Boolean) { + forwardState = isSeekEnabled + } + + fun onForwardSingleTap() { + repository.forwardJob = executeAfterTimeout(scope, repository.forwardJob) { + setForwardState(false) + forwardTapCount = 0 + } + forwardTapCount++ + } + + fun onForwardDoubletap() { + if(rewindState) return + isControlsVisible = false + onForwardSingleTap() + setForwardState(true) + } + + fun onRewindSingleTap() { + repository.rewindJob = executeAfterTimeout(scope, repository.rewindJob) { + setRewindState(false) + rewindTapCount = 0 + } + rewindTapCount++ + } + + fun onRewindDoubletap() { + if(forwardState) return + isControlsVisible = false + onRewindSingleTap() + setRewindState(true) + } + DisposableEffect(context) { val listener = object : Player.Listener { override fun onIsPlayingChanged(value: Boolean) { @@ -98,13 +189,7 @@ fun EnhancedVideoPlayer( } Box( - modifier = Modifier - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { isControlsVisible = !isControlsVisible } - ) - .testTag("VideoPlayerParent") + modifier = Modifier.testTag("VideoPlayerParent") ) { AndroidView( factory = { factoryContext -> @@ -119,6 +204,40 @@ fun EnhancedVideoPlayer( } } ) + Row( + modifier = Modifier + .fillMaxSize() + .background( + if (rewindState || forwardState) { + Color.Black.copy(alpha = 0.5f) + } else { + Color.Transparent + } + ).clickable { toggleIsControlsVisible() } + ) { + SeekClickableArea( + modifier = Modifier.weight(1f), + seekState = rewindState, + scaleAnimation = scale.value, + onSeekDoubleTap = ::onRewindDoubletap, + onSeekSingleTap = ::onRewindSingleTap, + toggleIsControlsVisible = ::toggleIsControlsVisible, + getSeekTime = { (transformSeekIncrementRatio(rewindTapCount) / 1000).toInt() }, + seekIcon = { controlsCustomization.rewindIconContent(it) } + ) + Spacer(modifier = Modifier.weight(0.2f)) + SeekClickableArea( + modifier = Modifier.weight(1f), + seekState = forwardState, + scaleAnimation = scale.value, + onSeekDoubleTap = ::onForwardDoubletap, + onSeekSingleTap = ::onForwardSingleTap, + toggleIsControlsVisible = ::toggleIsControlsVisible, + getSeekTime = { (transformSeekIncrementRatio(forwardTapCount) / 1000).toInt() }, + seekIcon = { controlsCustomization.forwardIconContent(it) } + ) + } + PlayerControls( title = title, isVisible = isControlsVisible, diff --git a/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerControls.kt b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerControls.kt index 748d289f..38338c08 100644 --- a/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerControls.kt +++ b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerControls.kt @@ -13,11 +13,14 @@ class ControlsCustomization( val nextIconContent: @Composable () -> Unit = { NextIcon() }, val fullScreenIconContent: @Composable () -> Unit = { FullScreenIcon() }, val exitFullScreenIconContent: @Composable () -> Unit = { ExitFullScreenIcon() }, - val settingsIconContent: @Composable () -> Unit = { SettingsIcon() } + val settingsIconContent: @Composable () -> Unit = { SettingsIcon() }, + val forwardIconContent: @Composable (modifier: Modifier) -> Unit = { ForwardIcon(it) }, + val rewindIconContent: @Composable (modifier: Modifier) -> Unit = { RewindIcon(it) } ) @Composable fun PlayerControls( + modifier: Modifier = Modifier, title: String? = null, isVisible: Boolean, isPlaying: Boolean, @@ -30,8 +33,7 @@ fun PlayerControls( onFullScreenToggle: () -> Unit, onSpeedSelected: (Float) -> Unit, customization: ControlsCustomization, - settingsControlsCustomization: SettingsControlsCustomization, - modifier: Modifier = Modifier + settingsControlsCustomization: SettingsControlsCustomization ) { PlayerControlsScaffold( modifier = modifier.testTag("PlayerControlsParent"), diff --git a/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerIcons.kt b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerIcons.kt index 758a83f0..ab7f575b 100644 --- a/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerIcons.kt +++ b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/components/playerOverlay/PlayerIcons.kt @@ -1,11 +1,13 @@ package com.profusion.androidenhancedvideoplayer.components.playerOverlay import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.profusion.androidenhancedvideoplayer.R @Composable @@ -95,3 +97,21 @@ fun CheckIcon(modifier: Modifier = Modifier) { modifier = modifier ) } + +@Composable +fun ForwardIcon(modifier: Modifier = Modifier) { + Image( + painter = painterResource(id = R.drawable.ic_forward), + contentDescription = stringResource(R.string.controls_forward_description), + modifier = Modifier.size(40.dp).testTag("ForwardIcon").then(modifier) + ) +} + +@Composable +fun RewindIcon(modifier: Modifier = Modifier) { + Image( + painter = painterResource(id = R.drawable.ic_rewind), + contentDescription = stringResource(R.string.controls_rewind_description), + modifier = Modifier.size(40.dp).testTag("RewindIcon").then(modifier) + ) +} diff --git a/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/utils/ExecuteAfterTimeout.kt b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/utils/ExecuteAfterTimeout.kt new file mode 100644 index 00000000..661dec11 --- /dev/null +++ b/androidenhancedvideoplayer/src/main/java/com/profusion/androidenhancedvideoplayer/utils/ExecuteAfterTimeout.kt @@ -0,0 +1,27 @@ +package com.profusion.androidenhancedvideoplayer.utils + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val TAG = "EXECUTE_AFTER_TIMEOUT" + +fun executeAfterTimeout( + scope: CoroutineScope, + job: Job?, + timeInMillis: Long = 650L, + onJobComplete: () -> Unit +): Job { + job?.cancel() + return scope.launch() { + try { + delay(timeInMillis) + onJobComplete() + } catch (e: CancellationException) { + Log.i(TAG, e.stackTraceToString()) + } + } +}