Skip to content

Commit

Permalink
feat: use SeekClickableArea component
Browse files Browse the repository at this point in the history
* use SeekClickableArea component
* create executeAfterTimeout method
* add forward/rewind icons to PlayerIcons file

Closes #15
  • Loading branch information
Thalys Matias Carrara committed Jun 7, 2023
1 parent 3c4e1b7 commit 7dabe86
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class PlayerControlsTest {
@Test
fun playerControls_WhenPressedSettingsButtonShouldShowSettings() {
composeTestRule.setContent {
defaultPlayerControls()
DefaultPlayerControls()
}

composeTestRule.onNodeWithTag("SettingsControlsParent").assertDoesNotExist()
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -15,7 +15,7 @@ class SettingsTest {
@Test
fun settings_WhenClickingOnSpeedButtonShouldShowSpeedSelector() {
composeTestRule.setContent {
defaultPlayerControls()
DefaultPlayerControls()
}

composeTestRule.onNodeWithTag("Playback SpeedSettingsSelector").assertDoesNotExist()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@ 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
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
Expand All @@ -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(
Expand All @@ -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 {
Expand All @@ -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) }
Expand All @@ -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) {
Expand Down Expand Up @@ -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 ->
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
)
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
}

0 comments on commit 7dabe86

Please sign in to comment.