diff --git a/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt b/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt index 3f48125..a5494c8 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/NewPlayer.kt @@ -45,7 +45,7 @@ interface NewPlayer { val bufferedPercentage: Int val repository: MediaRepository var currentPosition: Long - var fastSeekAmountSec: Long + var fastSeekAmountSec: Int var playBackMode: PlayMode var playList: MutableList @@ -54,6 +54,7 @@ interface NewPlayer { fun pause() fun fastSeekForward() fun fastSeekBackward() + fun seekTo(millisecond: Long) fun addToPlaylist(newItem: String) fun addListener(callbackListener: Listener) @@ -88,7 +89,7 @@ class NewPlayerImpl(override val internal_player: Player, override val repositor override val duartion: Long = internal_player.duration override val bufferedPercentage: Int = internal_player.bufferedPercentage override var currentPosition: Long = internal_player.currentPosition - override var fastSeekAmountSec: Long = 100 + override var fastSeekAmountSec: Int = 10 override var playBackMode: PlayMode = PlayMode.EMBEDDED_VIDEO override var playList: MutableList = ArrayList() @@ -115,11 +116,17 @@ class NewPlayerImpl(override val internal_player: Player, override val repositor } override fun fastSeekForward() { - Log.d(TAG, "not implemented fast seek forward") + val currentPosition = internal_player.currentPosition + internal_player.seekTo(currentPosition + fastSeekAmountSec * 1000) } override fun fastSeekBackward() { - Log.d(TAG, "not implemented fast seek backward") + val currentPosition = internal_player.currentPosition + internal_player.seekTo(currentPosition - fastSeekAmountSec * 1000) + } + + override fun seekTo(millisecond: Long) { + internal_player.seekTo(millisecond) } override fun addToPlaylist(newItem: String) { diff --git a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt index 79bcdda..ff0020f 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerUIState.kt @@ -37,7 +37,8 @@ data class VideoPlayerUIState( val bufferedPercentage: Float, val isLoading: Boolean, val durationInMs: Long, - val playbackPositionInMs: Long + val playbackPositionInMs: Long, + val fastseekSeconds: Int, ) : Parcelable { companion object { val DEFAULT = VideoPlayerUIState( @@ -52,7 +53,8 @@ data class VideoPlayerUIState( bufferedPercentage = 0f, isLoading = true, durationInMs = 0, - playbackPositionInMs = 0 + playbackPositionInMs = 0, + fastseekSeconds = 10 ) } } \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt index 6404e9e..42878de 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModelImpl.kt @@ -65,6 +65,7 @@ class VideoPlayerViewModelImpl @Inject constructor( set(value) { field = value installExoPlayer() + mutableUiState.update { it.copy(fastseekSeconds = field?.fastSeekAmountSec ?: 10) } } override val uiState = mutableUiState.asStateFlow() @@ -259,7 +260,7 @@ class VideoPlayerViewModelImpl @Inject constructor( resetHideUiDelayedJob() val seekerPosition = mutableUiState.value.seekerPosition val seekPositionInMs = (player?.duration?.toFloat() ?: 0F) * seekerPosition - player?.seekTo(seekPositionInMs.toLong()) + newPlayer?.seekTo(seekPositionInMs.toLong()) Log.i(TAG, "Seek to Ms: $seekPositionInMs") } @@ -268,6 +269,7 @@ class VideoPlayerViewModelImpl @Inject constructor( } override fun fastSeekForward() { + mutableUiState.update { it.copy(fastseekSeconds = newPlayer?.fastSeekAmountSec ?: 10) } newPlayer?.fastSeekForward() if (mutableUiState.value.uiVisible) { resetHideUiDelayedJob() @@ -275,6 +277,7 @@ class VideoPlayerViewModelImpl @Inject constructor( } override fun fastSeekBackward() { + mutableUiState.update { it.copy(fastseekSeconds = newPlayer?.fastSeekAmountSec ?: 10) } newPlayer?.fastSeekBackward() if (mutableUiState.value.uiVisible) { resetHideUiDelayedJob() diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt index c0e47ff..71ac7d2 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt @@ -62,6 +62,7 @@ fun VideoPlayerControllerUI( durationInMs: Long, playbackPositionInMs: Long, bufferedPercentage: Float, + fastSeekSeconds: Int, play: () -> Unit, pause: () -> Unit, prevStream: () -> Unit, @@ -101,7 +102,8 @@ fun VideoPlayerControllerUI( switchToEmbeddedView = switchToEmbeddedView, embeddedDraggedDownBy = embeddedDraggedDownBy, fastSeekForward = fastSeekForward, - fastSeekBackward = fastSeekBackward + fastSeekBackward = fastSeekBackward, + fastSeekSeconds = fastSeekSeconds ) } @@ -136,7 +138,8 @@ fun VideoPlayerControllerUI( switchToEmbeddedView = switchToEmbeddedView, embeddedDraggedDownBy = embeddedDraggedDownBy, fastSeekForward = fastSeekForward, - fastSeekBackward = fastSeekBackward + fastSeekBackward = fastSeekBackward, + fastSeekSeconds = fastSeekSeconds ) Box(modifier = Modifier.fillMaxSize()) { @@ -219,6 +222,7 @@ fun VideoPlayerControllerUIPreviewEmbedded() { durationInMs = 9*60*1000, playbackPositionInMs = 6*60*1000, bufferedPercentage = 0.4f, + fastSeekSeconds = 10, play = {}, pause = {}, prevStream = {}, @@ -249,6 +253,7 @@ fun VideoPlayerControllerUIPreviewLandscape() { durationInMs = 9*60*1000, playbackPositionInMs = 6*60*1000, bufferedPercentage = 0.4f, + fastSeekSeconds = 10, play = {}, pause = {}, prevStream = {}, @@ -280,6 +285,7 @@ fun VideoPlayerControllerUIPreviewPortrait() { durationInMs = 9*60*1000, playbackPositionInMs = 6*60*1000, bufferedPercentage = 0.4f, + fastSeekSeconds = 10, play = {}, pause = {}, prevStream = {}, diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerLoadingPlaceholder.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerLoadingPlaceholder.kt index 31351fa..6f56057 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerLoadingPlaceholder.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerLoadingPlaceholder.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -30,6 +31,7 @@ fun VideoPlayerLoadingPlaceholder(aspectRatio: Float = 3F / 1F) { .height(64.dp) .align((Alignment.Center))) } + } } diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt index 55507b1..c0e0a4a 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt @@ -152,6 +152,7 @@ fun VideoPlayerUI( durationInMs = uiState.durationInMs, playbackPositionInMs = uiState.playbackPositionInMs, bufferedPercentage = uiState.bufferedPercentage, + fastSeekSeconds = uiState.fastseekSeconds, play = viewModel::play, pause = viewModel::pause, prevStream = viewModel::prevStream, diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Color.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Color.kt index d5a00a4..35b7a76 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Color.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Color.kt @@ -83,6 +83,7 @@ fun VideoPlayerControllerUIPreviewEmbeddedColorPreview() { durationInMs = 9*60*1000, playbackPositionInMs = 6*60*1000, bufferedPercentage = 0.4f, + fastSeekSeconds = 10, play = {}, pause = {}, prevStream = {}, diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/GestureUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/GestureUI.kt index b8bd38a..f8bf429 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/GestureUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/videoplayer/GestureUI.kt @@ -22,23 +22,43 @@ package net.newpipe.newplayer.ui.videoplayer import android.util.Log import android.view.MotionEvent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Icon import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import net.newpipe.newplayer.R +import net.newpipe.newplayer.ui.theme.VideoPlayerTheme private const val TAG = "TouchUi" @@ -47,7 +67,10 @@ private data class TouchedPosition(val x: Float, val y: Float) { } const val DELAY_UNTIL_SHOWING_UI_AFTER_TOUCH_IN_MS: Long = 200 - +const val SEEK_ANIMATION_DURATION_IN_MS = 400 +const val SEEK_ANIMATION_VISSIBLE_IN_MS = 500L +const val SEEK_ANIMATION_FADE_IN = 200 +const val SEEK_ANIMATION_FADE_OUT = 500 @Composable fun GestureUI( @@ -56,6 +79,7 @@ fun GestureUI( showUi: () -> Unit, uiVissible: Boolean, fullscreen: Boolean, + fastSeekSeconds: Int, switchToFullscreen: () -> Unit, switchToEmbeddedView: () -> Unit, embeddedDraggedDownBy: (Float) -> Unit, @@ -70,14 +94,53 @@ fun GestureUI( } } + var showFastSeekBack by remember { + mutableStateOf(false) + } + + var showFastSeekForward by remember { + mutableStateOf(false) + } + + val composeScope = rememberCoroutineScope() + + val doForwardSeek = { + showFastSeekForward = true + composeScope.launch { + delay(SEEK_ANIMATION_VISSIBLE_IN_MS) + showFastSeekForward = false + } + fastSeekForward() + } + + val doBackwardSeek = { + showFastSeekBack = true + composeScope.launch { + delay(SEEK_ANIMATION_VISSIBLE_IN_MS) + showFastSeekBack = false + } + fastSeekBackward() + } + + if (fullscreen) { Row(modifier = modifier) { TouchSurface( modifier = Modifier .weight(1f), onRegularTap = defaultOnRegularTap, - onDoubleTab = fastSeekBackward - ) + onDoubleTab = doBackwardSeek + ) { + FadedAnimationForSeekFeedback(visible = showFastSeekBack) { + Box(modifier = Modifier.fillMaxSize()) { + FastSeekVisualFeedback( + seconds = fastSeekSeconds, + backwards = true, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } TouchSurface( modifier = Modifier .weight(1f), @@ -92,8 +155,18 @@ fun GestureUI( modifier = Modifier .weight(1f), onRegularTap = defaultOnRegularTap, - onDoubleTab = fastSeekForward - ) + onDoubleTab = doForwardSeek + ) { + FadedAnimationForSeekFeedback(visible = showFastSeekForward) { + Box(modifier = Modifier.fillMaxSize()) { + FastSeekVisualFeedback( + modifier = Modifier.align(Alignment.CenterStart), + seconds = fastSeekSeconds, + backwards = false + ) + } + } + } } } else { // (!fullscreen) val handleDownwardMovement = { movement: TouchedPosition -> @@ -109,21 +182,52 @@ fun GestureUI( TouchSurface( modifier = Modifier .weight(1f), - onDoubleTab = fastSeekBackward, + onDoubleTab = doBackwardSeek, onRegularTap = defaultOnRegularTap, onMovement = handleDownwardMovement - ) + ) { + FadedAnimationForSeekFeedback(visible = showFastSeekBack) { + Box(modifier = Modifier.fillMaxSize()) { + FastSeekVisualFeedback( + modifier = Modifier.align(Alignment.Center), + seconds = fastSeekSeconds, + backwards = true + ) + } + } + } TouchSurface( modifier = Modifier .weight(1f), - onDoubleTab = fastSeekForward, + onDoubleTab = doForwardSeek, onRegularTap = defaultOnRegularTap, onMovement = handleDownwardMovement - ) + ) { + FadedAnimationForSeekFeedback(visible = showFastSeekForward) { + Box(modifier = Modifier.fillMaxSize()) { + FastSeekVisualFeedback( + modifier = Modifier.align(Alignment.Center), + seconds = fastSeekSeconds, + backwards = false + ) + } + } + } } } } +@Composable +fun FadedAnimationForSeekFeedback(visible: Boolean, content: @Composable () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(SEEK_ANIMATION_FADE_IN)), + exit = fadeOut(animationSpec = tween(SEEK_ANIMATION_FADE_OUT)) + ) { + content() + } +} + @Composable @OptIn(ExperimentalComposeUiApi::class) private fun TouchSurface( @@ -131,7 +235,8 @@ private fun TouchSurface( color: Color = Color.Transparent, onDoubleTab: () -> Unit = {}, onRegularTap: () -> Unit = {}, - onMovement: (TouchedPosition) -> Unit = {} + onMovement: (TouchedPosition) -> Unit = {}, + content: @Composable () -> Unit = {} ) { var moveOccured by remember { mutableStateOf(false) @@ -194,6 +299,169 @@ private fun TouchSurface( else -> false } }) { + content() Surface(color = color, modifier = Modifier.fillMaxSize()) {} } +} + +@Composable +fun FastSeekVisualFeedback(modifier: Modifier = Modifier, seconds: Int, backwards: Boolean) { + + val contentDescription = String.format( + if (backwards) { + "Fast seeking backward by %d seconds." + //stringResource(id = R.string.fast_seeking_backward) + } else { + "Fast seeking forward by %d seconds." + //stringResource(id = R.string.fast_seeking_forward) + }, seconds + ) + + val infiniteTransition = rememberInfiniteTransition() + + val animatedColor1 by infiniteTransition.animateColor( + initialValue = Color.White, + targetValue = Color.Transparent, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = SEEK_ANIMATION_DURATION_IN_MS + Color.White.copy(alpha = 1f) at 0 with LinearEasing + Color.White.copy(alpha = 0f) at SEEK_ANIMATION_DURATION_IN_MS with LinearEasing + }, + repeatMode = RepeatMode.Restart + ), label = "Arrow1 animation" + ) + + val animatedColor2 by infiniteTransition.animateColor( + initialValue = Color.White, + targetValue = Color.Transparent, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = SEEK_ANIMATION_DURATION_IN_MS + Color.White.copy(alpha = 1f / 3f) at 0 with LinearEasing + Color.White.copy(alpha = 0f) at SEEK_ANIMATION_DURATION_IN_MS / 3 with LinearEasing + Color.White.copy(alpha = 1f) at SEEK_ANIMATION_DURATION_IN_MS / 3 + 1 with LinearEasing + Color.White.copy(alpha = 2f / 3f) at SEEK_ANIMATION_DURATION_IN_MS with LinearEasing + }, + repeatMode = RepeatMode.Restart + ), label = "Arrow2 animation" + ) + + val animatedColor3 by infiniteTransition.animateColor( + initialValue = Color.White, + targetValue = Color.Transparent, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = SEEK_ANIMATION_DURATION_IN_MS + Color.White.copy(alpha = 2f / 3f) at 0 with LinearEasing + Color.White.copy(alpha = 0f) at 2 * SEEK_ANIMATION_DURATION_IN_MS / 3 with LinearEasing + Color.White.copy(alpha = 1f) at 2 * SEEK_ANIMATION_DURATION_IN_MS / 3 + 1 with LinearEasing + Color.White.copy(alpha = 2f / 3f) at SEEK_ANIMATION_DURATION_IN_MS with LinearEasing + }, + repeatMode = RepeatMode.Restart + ), label = "Arrow3 animation" + ) + + + //val secondsString = stringResource(id = R.string.seconds) + val secondsString = "Seconds" + + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Row { + SeekerIcon( + backwards = backwards, + description = contentDescription, + color = if (backwards) animatedColor3 else animatedColor1 + ) + SeekerIcon( + backwards = backwards, + description = contentDescription, + color = animatedColor2 + ) + SeekerIcon( + backwards = backwards, + description = contentDescription, + color = if (backwards) animatedColor1 else animatedColor3 + ) + } + Text(text = "$seconds $secondsString") + } + +} + + +@Composable +fun SeekerIcon(backwards: Boolean, description: String, color: Color) { + Icon( + modifier = if (backwards) { + Modifier.scale(-1f, 1f) + } else { + Modifier + }, + tint = color, + painter = painterResource(id = R.drawable.ic_play_seek_triangle), + contentDescription = description + ) +} + +@Preview(device = "spec:width=1080px,height=600px,dpi=440,orientation=landscape") +@Composable +fun FullscreenGestureUIPreview() { + VideoPlayerTheme { + Surface(modifier = Modifier.wrapContentSize(), color = Color.Black) { + GestureUI( + modifier = Modifier, + hideUi = { }, + showUi = { }, + uiVissible = false, + fullscreen = true, + fastSeekSeconds = 10, + switchToFullscreen = { println("switch to fullscreen") }, + switchToEmbeddedView = { println("switch to embedded") }, + embeddedDraggedDownBy = { println("embedded dragged down") }, + fastSeekBackward = { println("fast seek backward") }, + fastSeekForward = { println("fast seek forward") }) + } + } +} + +@Preview(device = "spec:width=600px,height=400px,dpi=440,orientation=landscape") +@Composable +fun EmbeddedGestureUIPreview() { + VideoPlayerTheme { + Surface(modifier = Modifier.wrapContentSize(), color = Color.Black) { + GestureUI( + modifier = Modifier, + hideUi = { }, + showUi = { }, + uiVissible = false, + fullscreen = false, + fastSeekSeconds = 10, + switchToFullscreen = { println("switch to fullscreen") }, + switchToEmbeddedView = { println("switch to embedded") }, + embeddedDraggedDownBy = { println("embedded dragged down") }, + fastSeekBackward = { println("fast seek backward") }, + fastSeekForward = { println("fast seek forward") }) + } + } +} + +@Preview(device = "spec:width=1080px,height=600px,dpi=440,orientation=landscape") +@Composable +fun FastSeekVisualFeedbackPreviewBackwards() { + VideoPlayerTheme { + Surface(modifier = Modifier.wrapContentSize(), color = Color.Black) { + FastSeekVisualFeedback(seconds = 10, backwards = true) + } + } +} + +@Preview(device = "spec:width=1080px,height=600px,dpi=440,orientation=landscape") +@Composable +fun FastSeekVisualFeedbackPreview() { + VideoPlayerTheme { + Surface(modifier = Modifier.wrapContentSize(), color = Color.Black) { + FastSeekVisualFeedback(seconds = 10, backwards = false) + } + } } \ No newline at end of file diff --git a/test-app/src/main/res/drawable/ic_play_seek_triangle.xml b/new-player/src/main/res/drawable/ic_play_seek_triangle.xml similarity index 100% rename from test-app/src/main/res/drawable/ic_play_seek_triangle.xml rename to new-player/src/main/res/drawable/ic_play_seek_triangle.xml diff --git a/new-player/src/main/res/values/strings.xml b/new-player/src/main/res/values/strings.xml index ffbc964..6f4a123 100644 --- a/new-player/src/main/res/values/strings.xml +++ b/new-player/src/main/res/values/strings.xml @@ -35,4 +35,7 @@ Toggle fullscreen Chapter selection Playlist item selection + Fast seeking backward by %d seconds. + Fast seeking forward by %d seconds. + Seconds \ No newline at end of file