diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 2de1333a4..7ae36d398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -21,8 +21,7 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.annotation.OptIn -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -31,16 +30,22 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW +import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons +import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform +import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel private fun getVideoSizeDp(player: Player): Size? { @@ -71,21 +76,57 @@ fun RenderVideoPlayer( controllerVisible: MutableState = remember { mutableStateOf(false) }, accountViewModel: AccountViewModel, ) { - Box(modifier = borderModifier) { + val containerSize = remember { mutableStateOf(IntSize.Zero) } + val isLive = isLiveStreaming(mediaItem.src.videoUri) + val skipSeconds = if (controllerState.controller.duration in 1..30000) 5 else 10 + + Box( + modifier = + borderModifier + .onSizeChanged { containerSize.value = it } + .pointerInput(Unit) { + detectTapGestures( + onTap = { controllerVisible.value = !controllerVisible.value }, + onDoubleTap = { offset -> + if (!isLive) { + val isLeftSide = offset.x < containerSize.value.width / 2 + if (isLeftSide) { + val newPosition = + (controllerState.controller.currentPosition - skipSeconds * 1000) + .coerceAtLeast(0) + controllerState.controller.seekTo(newPosition) + } else { + val duration = controllerState.controller.duration + val newPosition = + (controllerState.controller.currentPosition + skipSeconds * 1000) + .coerceAtMost(duration) + controllerState.controller.seekTo(newPosition) + } + } + }, + ) + }, + ) { ContentFrame( player = controllerState.controller, - modifier = - videoModifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, // to prevent the ripple from the tap - ) { controllerVisible.value = !controllerVisible.value }, - surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts. + modifier = videoModifier, + surfaceType = SURFACE_TYPE_TEXTURE_VIEW, contentScale = contentScale, ) mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) } if (showControls) { + TopGradientOverlay( + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.TopCenter), + ) + + BottomGradientOverlay( + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.BottomCenter), + ) + RenderTopButtons( mediaData = mediaItem.src, controllerState = controllerState, @@ -95,7 +136,13 @@ fun RenderVideoPlayer( accountViewModel = accountViewModel, ) - RenderCenterButtons(controllerState, controllerVisible, Modifier.align(Alignment.Center)) + RenderCenterButtons( + controllerState = controllerState, + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.Center), + isLiveStream = isLive, + videoDurationMs = controllerState.controller.duration, + ) RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt index 44b0ec8d1..88628f69a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt @@ -98,10 +98,10 @@ private fun HorizontalLinearProgressIndicator( onLayoutWidthChanged: (Int) -> Unit = {}, onSeek: (Float) -> Unit, playedColor: Color = Color.White, - bufferedColor: Color = Color.LightGray, - unplayedColor: Color = Color.DarkGray, + bufferedColor: Color = Color.White.copy(alpha = 0.5f), + unplayedColor: Color = Color.White.copy(alpha = 0.3f), scrubberColor: Color = playedColor, - rectHeightDp: Dp = 3.dp, + rectHeightDp: Dp = 4.dp, ) { Canvas( Modifier @@ -125,7 +125,7 @@ private fun HorizontalLinearProgressIndicator( drawCircle( color = scrubberColor, - radius = size.height * 1.5f, + radius = size.height * 2f, center = Offset(x = positionX, y = size.height / 2.0f), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index 79ce2cbb2..f656c60bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.SaveAlt @@ -64,11 +63,9 @@ fun OverflowMenuButtonPreview() { OverflowMenuButton( showShare = true, showSave = true, - showFullscreen = true, showPip = true, onShareClick = {}, onSaveClick = {}, - onFullscreenClick = {}, onPipClick = {}, ) } @@ -81,11 +78,9 @@ fun AnimatedOverflowMenuButton( modifier: Modifier = Modifier, showShare: Boolean = true, showSave: Boolean = true, - showFullscreen: Boolean = false, showPip: Boolean = false, onShareClick: () -> Unit, onSaveClick: () -> Unit, - onFullscreenClick: () -> Unit, onPipClick: () -> Unit, ) { AnimatedVisibility( @@ -97,11 +92,9 @@ fun AnimatedOverflowMenuButton( OverflowMenuButton( showShare = showShare, showSave = showSave, - showFullscreen = showFullscreen, showPip = showPip, onShareClick = onShareClick, onSaveClick = onSaveClick, - onFullscreenClick = onFullscreenClick, onPipClick = onPipClick, ) } @@ -111,11 +104,9 @@ fun AnimatedOverflowMenuButton( fun OverflowMenuButton( showShare: Boolean, showSave: Boolean, - showFullscreen: Boolean, showPip: Boolean, onShareClick: () -> Unit, onSaveClick: () -> Unit, - onFullscreenClick: () -> Unit, onPipClick: () -> Unit, ) { val menuExpanded = remember { mutableStateOf(false) } @@ -180,23 +171,6 @@ fun OverflowMenuButton( ) } - if (showFullscreen) { - DropdownMenuItem( - text = { Text(stringRes(R.string.fullscreen), color = Color.White) }, - onClick = { - menuExpanded.value = false - onFullscreenClick() - }, - leadingIcon = { - Icon( - Icons.Default.Fullscreen, - contentDescription = null, - tint = Color.White, - ) - }, - ) - } - if (showPip) { DropdownMenuItem( text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt index 45c554f35..182e446bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -21,10 +21,14 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState @@ -36,16 +40,48 @@ fun RenderCenterButtons( controllerState: MediaControllerState, controllerVisible: MutableState, modifier: Modifier, + isLiveStream: Boolean = false, + videoDurationMs: Long = 0L, ) { val state = rememberPlayPauseButtonState(controllerState.controller) + val skipSeconds = if (videoDurationMs in 1..30000) 5 else 10 - AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) { - state.onClick() + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isLiveStream) { + AnimatedSkipButton( + controllerVisible = controllerVisible, + isForward = false, + skipSeconds = skipSeconds, + ) { + val newPosition = (controllerState.controller.currentPosition - skipSeconds * 1000).coerceAtLeast(0) + controllerState.controller.seekTo(newPosition) + } + } + + AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) { + state.onClick() + } + + if (!isLiveStream) { + AnimatedSkipButton( + controllerVisible = controllerVisible, + isForward = true, + skipSeconds = skipSeconds, + ) { + val duration = controllerState.controller.duration + val newPosition = (controllerState.controller.currentPosition + skipSeconds * 1000).coerceAtMost(duration) + controllerState.controller.seekTo(newPosition) + } + } } if (!state.showPlay) { LaunchedEffect(state.showPlay) { - delay(2000) + delay(3000) controllerVisible.value = false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index e103ec1e4..bb26bd0d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import android.content.pm.PackageManager +import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -52,9 +54,11 @@ fun RenderTopButtonsPreview() { mediaData = MediaItemData("http://test.mp4"), controllerVisible = remember { mutableStateOf(true) }, startingMuteState = false, - onMuteClick = { }, - onPictureInPictureClick = { }, - onZoomClick = { }, + isLive = false, + pipSupported = true, + onMuteClick = {}, + onPictureInPictureClick = {}, + onZoomClick = {}, modifier = Modifier, accountViewModel = mockAccountViewModel(), ) @@ -71,20 +75,30 @@ fun RenderTopButtons( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val context = LocalContext.current.getActivity() + val context = LocalContext.current + val isLive = isLiveStreaming(mediaData.videoUri) + val pipSupported = + remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } else { + false + } + } RenderTopButtons( mediaData = mediaData, controllerVisible = controllerVisible, startingMuteState = controllerState.controller.volume < 0.001, + isLive = isLive, + pipSupported = pipSupported, onMuteClick = { mute -> - // makes the new setting the default for new creations. DEFAULT_MUTED_SETTING.value = mute controllerState.controller.volume = if (mute) 0f else 1f }, onPictureInPictureClick = { controllerState.controller.pause() - PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context) + PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity()) }, onZoomClick = onZoomClick?.let { @@ -103,49 +117,18 @@ fun RenderTopButtons( mediaData: MediaItemData, controllerVisible: MutableState, startingMuteState: Boolean, + isLive: Boolean, + pipSupported: Boolean, onMuteClick: (Boolean) -> Unit, onPictureInPictureClick: () -> Unit, onZoomClick: (() -> Unit)?, modifier: Modifier, accountViewModel: AccountViewModel, ) { + val context = LocalContext.current + val shareDialogVisible = remember { mutableStateOf(false) } + Row(modifier) { - if (!isLiveStreaming(mediaData.videoUri)) { - AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> - ShareMediaAction( - popupExpanded = popupExpanded, - videoUri = mediaData.videoUri, - postNostrUri = mediaData.callbackUri, - blurhash = null, - dim = null, - hash = null, - mimeType = mediaData.mimeType, - onDismiss = toggle, - content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), - accountViewModel = accountViewModel, - ) - } - - AnimatedSaveButton(controllerVisible) { context -> - accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) - } - } else { - AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> - ShareMediaAction( - popupExpanded = popupExpanded, - videoUri = mediaData.videoUri, - postNostrUri = mediaData.callbackUri, - blurhash = null, - dim = null, - hash = null, - mimeType = mediaData.mimeType, - onDismiss = toggle, - content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), - accountViewModel = accountViewModel, - ) - } - } - if (onZoomClick != null) { FullScreenButton( controllerVisible = controllerVisible, @@ -153,15 +136,43 @@ fun RenderTopButtons( ) } - PictureInPictureButton( - controllerVisible = controllerVisible, - onClick = onPictureInPictureClick, - ) - MuteButton( controllerVisible = controllerVisible, startingMuteState = startingMuteState, toggle = onMuteClick, ) + + AnimatedOverflowMenuButton( + controllerVisible = controllerVisible, + showShare = true, + showSave = !isLive, + showPip = pipSupported, + onShareClick = { shareDialogVisible.value = true }, + onSaveClick = { + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + }, + onPipClick = onPictureInPictureClick, + ) } + + ShareMediaAction( + popupExpanded = shareDialogVisible, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = { shareDialogVisible.value = false }, + content = + MediaUrlVideo( + url = mediaData.videoUri, + mimeType = mediaData.mimeType, + artworkUri = mediaData.artworkUri, + authorName = mediaData.authorName, + description = mediaData.title, + uri = mediaData.callbackUri, + ), + accountViewModel = accountViewModel, + ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index da243bd44..e98d1c14e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -903,7 +903,6 @@ Sound on. Click to mute Skip back %d seconds Skip forward %d seconds - Fullscreen Picture-in-Picture LIVE Tap to retry