simplify top buttons, move to overflow

add skip buttons
add gradient overlays and double-tap gesture support
This commit is contained in:
davotoula
2026-02-26 18:17:13 +00:00
parent eb57811654
commit 3e9475087e
6 changed files with 158 additions and 91 deletions
@@ -21,8 +21,7 @@
package com.vitorpamplona.amethyst.service.playback.composable package com.vitorpamplona.amethyst.service.playback.composable
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -31,16 +30,22 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale 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.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW 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.RenderAnimatedBottomInfo
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons 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.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.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform 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 import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
private fun getVideoSizeDp(player: Player): Size? { private fun getVideoSizeDp(player: Player): Size? {
@@ -71,21 +76,57 @@ fun RenderVideoPlayer(
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) }, controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
accountViewModel: AccountViewModel, 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( ContentFrame(
player = controllerState.controller, player = controllerState.controller,
modifier = modifier = videoModifier,
videoModifier.clickable( surfaceType = SURFACE_TYPE_TEXTURE_VIEW,
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.
contentScale = contentScale, contentScale = contentScale,
) )
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) } mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
if (showControls) { if (showControls) {
TopGradientOverlay(
controllerVisible = controllerVisible,
modifier = Modifier.align(Alignment.TopCenter),
)
BottomGradientOverlay(
controllerVisible = controllerVisible,
modifier = Modifier.align(Alignment.BottomCenter),
)
RenderTopButtons( RenderTopButtons(
mediaData = mediaItem.src, mediaData = mediaItem.src,
controllerState = controllerState, controllerState = controllerState,
@@ -95,7 +136,13 @@ fun RenderVideoPlayer(
accountViewModel = accountViewModel, 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)) RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter))
} }
@@ -98,10 +98,10 @@ private fun HorizontalLinearProgressIndicator(
onLayoutWidthChanged: (Int) -> Unit = {}, onLayoutWidthChanged: (Int) -> Unit = {},
onSeek: (Float) -> Unit, onSeek: (Float) -> Unit,
playedColor: Color = Color.White, playedColor: Color = Color.White,
bufferedColor: Color = Color.LightGray, bufferedColor: Color = Color.White.copy(alpha = 0.5f),
unplayedColor: Color = Color.DarkGray, unplayedColor: Color = Color.White.copy(alpha = 0.3f),
scrubberColor: Color = playedColor, scrubberColor: Color = playedColor,
rectHeightDp: Dp = 3.dp, rectHeightDp: Dp = 4.dp,
) { ) {
Canvas( Canvas(
Modifier Modifier
@@ -125,7 +125,7 @@ private fun HorizontalLinearProgressIndicator(
drawCircle( drawCircle(
color = scrubberColor, color = scrubberColor,
radius = size.height * 1.5f, radius = size.height * 2f,
center = Offset(x = positionX, y = size.height / 2.0f), center = Offset(x = positionX, y = size.height / 2.0f),
) )
} }
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.MoreVert
import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.filled.SaveAlt
@@ -64,11 +63,9 @@ fun OverflowMenuButtonPreview() {
OverflowMenuButton( OverflowMenuButton(
showShare = true, showShare = true,
showSave = true, showSave = true,
showFullscreen = true,
showPip = true, showPip = true,
onShareClick = {}, onShareClick = {},
onSaveClick = {}, onSaveClick = {},
onFullscreenClick = {},
onPipClick = {}, onPipClick = {},
) )
} }
@@ -81,11 +78,9 @@ fun AnimatedOverflowMenuButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showShare: Boolean = true, showShare: Boolean = true,
showSave: Boolean = true, showSave: Boolean = true,
showFullscreen: Boolean = false,
showPip: Boolean = false, showPip: Boolean = false,
onShareClick: () -> Unit, onShareClick: () -> Unit,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
onFullscreenClick: () -> Unit,
onPipClick: () -> Unit, onPipClick: () -> Unit,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -97,11 +92,9 @@ fun AnimatedOverflowMenuButton(
OverflowMenuButton( OverflowMenuButton(
showShare = showShare, showShare = showShare,
showSave = showSave, showSave = showSave,
showFullscreen = showFullscreen,
showPip = showPip, showPip = showPip,
onShareClick = onShareClick, onShareClick = onShareClick,
onSaveClick = onSaveClick, onSaveClick = onSaveClick,
onFullscreenClick = onFullscreenClick,
onPipClick = onPipClick, onPipClick = onPipClick,
) )
} }
@@ -111,11 +104,9 @@ fun AnimatedOverflowMenuButton(
fun OverflowMenuButton( fun OverflowMenuButton(
showShare: Boolean, showShare: Boolean,
showSave: Boolean, showSave: Boolean,
showFullscreen: Boolean,
showPip: Boolean, showPip: Boolean,
onShareClick: () -> Unit, onShareClick: () -> Unit,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
onFullscreenClick: () -> Unit,
onPipClick: () -> Unit, onPipClick: () -> Unit,
) { ) {
val menuExpanded = remember { mutableStateOf(false) } 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) { if (showPip) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) }, text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) },
@@ -21,10 +21,14 @@
package com.vitorpamplona.amethyst.service.playback.composable.controls package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
@@ -36,16 +40,48 @@ fun RenderCenterButtons(
controllerState: MediaControllerState, controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>, controllerVisible: MutableState<Boolean>,
modifier: Modifier, modifier: Modifier,
isLiveStream: Boolean = false,
videoDurationMs: Long = 0L,
) { ) {
val state = rememberPlayPauseButtonState(controllerState.controller) val state = rememberPlayPauseButtonState(controllerState.controller)
val skipSeconds = if (videoDurationMs in 1..30000) 5 else 10
AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) { Row(
state.onClick() 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) { if (!state.showPlay) {
LaunchedEffect(state.showPlay) { LaunchedEffect(state.showPlay) {
delay(2000) delay(3000)
controllerVisible.value = false controllerVisible.value = false
} }
} }
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.service.playback.composable.controls 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -52,9 +54,11 @@ fun RenderTopButtonsPreview() {
mediaData = MediaItemData("http://test.mp4"), mediaData = MediaItemData("http://test.mp4"),
controllerVisible = remember { mutableStateOf(true) }, controllerVisible = remember { mutableStateOf(true) },
startingMuteState = false, startingMuteState = false,
onMuteClick = { }, isLive = false,
onPictureInPictureClick = { }, pipSupported = true,
onZoomClick = { }, onMuteClick = {},
onPictureInPictureClick = {},
onZoomClick = {},
modifier = Modifier, modifier = Modifier,
accountViewModel = mockAccountViewModel(), accountViewModel = mockAccountViewModel(),
) )
@@ -71,20 +75,30 @@ fun RenderTopButtons(
modifier: Modifier, modifier: Modifier,
accountViewModel: AccountViewModel, 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( RenderTopButtons(
mediaData = mediaData, mediaData = mediaData,
controllerVisible = controllerVisible, controllerVisible = controllerVisible,
startingMuteState = controllerState.controller.volume < 0.001, startingMuteState = controllerState.controller.volume < 0.001,
isLive = isLive,
pipSupported = pipSupported,
onMuteClick = { mute -> onMuteClick = { mute ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute DEFAULT_MUTED_SETTING.value = mute
controllerState.controller.volume = if (mute) 0f else 1f controllerState.controller.volume = if (mute) 0f else 1f
}, },
onPictureInPictureClick = { onPictureInPictureClick = {
controllerState.controller.pause() controllerState.controller.pause()
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context) PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity())
}, },
onZoomClick = onZoomClick =
onZoomClick?.let { onZoomClick?.let {
@@ -103,49 +117,18 @@ fun RenderTopButtons(
mediaData: MediaItemData, mediaData: MediaItemData,
controllerVisible: MutableState<Boolean>, controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean, startingMuteState: Boolean,
isLive: Boolean,
pipSupported: Boolean,
onMuteClick: (Boolean) -> Unit, onMuteClick: (Boolean) -> Unit,
onPictureInPictureClick: () -> Unit, onPictureInPictureClick: () -> Unit,
onZoomClick: (() -> Unit)?, onZoomClick: (() -> Unit)?,
modifier: Modifier, modifier: Modifier,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val context = LocalContext.current
val shareDialogVisible = remember { mutableStateOf(false) }
Row(modifier) { 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) { if (onZoomClick != null) {
FullScreenButton( FullScreenButton(
controllerVisible = controllerVisible, controllerVisible = controllerVisible,
@@ -153,15 +136,43 @@ fun RenderTopButtons(
) )
} }
PictureInPictureButton(
controllerVisible = controllerVisible,
onClick = onPictureInPictureClick,
)
MuteButton( MuteButton(
controllerVisible = controllerVisible, controllerVisible = controllerVisible,
startingMuteState = startingMuteState, startingMuteState = startingMuteState,
toggle = onMuteClick, 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,
)
} }
-1
View File
@@ -903,7 +903,6 @@
<string name="mute_button">Sound on. Click to mute</string> <string name="mute_button">Sound on. Click to mute</string>
<string name="skip_back">Skip back %d seconds</string> <string name="skip_back">Skip back %d seconds</string>
<string name="skip_forward">Skip forward %d seconds</string> <string name="skip_forward">Skip forward %d seconds</string>
<string name="fullscreen">Fullscreen</string>
<string name="picture_in_picture">Picture-in-Picture</string> <string name="picture_in_picture">Picture-in-Picture</string>
<string name="live">LIVE</string> <string name="live">LIVE</string>
<string name="tap_to_retry">Tap to retry</string> <string name="tap_to_retry">Tap to retry</string>