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
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<Boolean> = 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))
}
@@ -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),
)
}
@@ -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) },
@@ -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<Boolean>,
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) {
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
}
}
@@ -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<Boolean>,
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,
)
}
-1
View File
@@ -903,7 +903,6 @@
<string name="mute_button">Sound on. Click to mute</string>
<string name="skip_back">Skip back %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="live">LIVE</string>
<string name="tap_to_retry">Tap to retry</string>