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..99a0b0883 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,18 +30,36 @@ 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 +internal const val SKIP_SECONDS = 10 +internal const val SKIP_MILLIS = SKIP_SECONDS * 1000L + +internal fun Player.seekBackward() { + seekTo((currentPosition - SKIP_MILLIS).coerceAtLeast(0)) +} + +internal fun Player.skipForward() { + val newPosition = currentPosition + SKIP_MILLIS + seekTo(if (duration > 0) newPosition.coerceAtMost(duration) else newPosition) +} + private fun getVideoSizeDp(player: Player): Size? { var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat()) @@ -71,21 +88,49 @@ fun RenderVideoPlayer( controllerVisible: MutableState = remember { mutableStateOf(false) }, accountViewModel: AccountViewModel, ) { - Box(modifier = borderModifier) { + val containerSize = remember { mutableStateOf(IntSize.Zero) } + val isLive = isLiveStreaming(mediaItem.src.videoUri) + + Box( + modifier = + borderModifier + .onSizeChanged { containerSize.value = it } + .pointerInput(isLive, controllerState) { + detectTapGestures( + onTap = { controllerVisible.value = !controllerVisible.value }, + onDoubleTap = { offset -> + if (!isLive) { + val isLeftSide = offset.x < containerSize.value.width / 2 + if (isLeftSide) { + controllerState.controller.seekBackward() + } else { + controllerState.controller.skipForward() + } + } + }, + ) + }, + ) { 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 +140,12 @@ fun RenderVideoPlayer( accountViewModel = accountViewModel, ) - RenderCenterButtons(controllerState, controllerVisible, Modifier.align(Alignment.Center)) + RenderCenterButtons( + controllerState = controllerState, + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.Center), + isLiveStream = isLive, + ) RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt new file mode 100644 index 000000000..938aa8ef9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val FadeIn = fadeIn() +private val FadeOut = fadeOut() + +private val TopGradientColors = + listOf( + Color.Black.copy(alpha = 0.6f), + Color.Black.copy(alpha = 0.3f), + Color.Transparent, + ) + +private val BottomGradientColors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.7f), + ) + +@Composable +private fun GradientOverlay( + controllerVisible: State, + colors: List, + height: Dp, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = FadeIn, + exit = FadeOut, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(height) + .background(brush = Brush.verticalGradient(colors = colors)), + ) + } +} + +@Composable +fun TopGradientOverlay( + controllerVisible: State, + modifier: Modifier = Modifier, + height: Dp = 80.dp, +) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier) + +@Composable +fun BottomGradientOverlay( + controllerVisible: State, + modifier: Modifier = Modifier, + height: Dp = 120.dp, +) = GradientOverlay(controllerVisible, BottomGradientColors, height, modifier) 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 new file mode 100644 index 000000000..491634b68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +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.MoreVert +import androidx.compose.material.icons.filled.PictureInPicture +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize + +private val FadeIn = fadeIn() +private val FadeOut = fadeOut() + +@Preview +@Composable +fun OverflowMenuButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + OverflowMenuButton( + showShare = true, + showSave = true, + showPip = true, + onShareClick = {}, + onSaveClick = {}, + onPipClick = {}, + ) + } + } +} + +@Composable +fun AnimatedOverflowMenuButton( + controllerVisible: State, + modifier: Modifier = Modifier, + showShare: Boolean = true, + showSave: Boolean = true, + showPip: Boolean = false, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, + onPipClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = FadeIn, + exit = FadeOut, + ) { + OverflowMenuButton( + showShare = showShare, + showSave = showSave, + showPip = showPip, + onShareClick = onShareClick, + onSaveClick = onSaveClick, + onPipClick = onPipClick, + ) + } +} + +@Composable +fun OverflowMenuButton( + showShare: Boolean, + showSave: Boolean, + showPip: Boolean, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, + onPipClick: () -> Unit, +) { + val menuExpanded = remember { mutableStateOf(false) } + + Box(modifier = VolumeBottomIconSize) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.7f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + onClick = { menuExpanded.value = true }, + modifier = Size50Modifier, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringRes(R.string.more_options), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size20Modifier, + ) + } + + DropdownMenu( + expanded = menuExpanded.value, + onDismissRequest = { menuExpanded.value = false }, + containerColor = Color.Black.copy(alpha = 0.85f), + ) { + if (showShare) { + DropdownMenuItem( + text = { Text(stringRes(R.string.share_or_save), color = Color.White) }, + onClick = { + menuExpanded.value = false + onShareClick() + }, + leadingIcon = { + Icon( + Icons.Default.Share, + contentDescription = null, + tint = Color.White, + ) + }, + ) + } + + if (showSave) { + DropdownMenuItem( + text = { Text(stringRes(R.string.save_to_gallery), color = Color.White) }, + onClick = { + menuExpanded.value = false + onSaveClick() + }, + leadingIcon = { + Icon( + Icons.Default.SaveAlt, + contentDescription = null, + tint = Color.White, + ) + }, + ) + } + + if (showPip) { + DropdownMenuItem( + text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) }, + onClick = { + menuExpanded.value = false + onPipClick() + }, + leadingIcon = { + Icon( + Icons.Default.PictureInPicture, + contentDescription = null, + tint = Color.White, + ) + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt index 1c3b53f2f..a23bd5491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Popup @@ -110,6 +111,7 @@ fun PlaybackSpeedPopUpButton( Box { Text( text = "%.1fx".format(playbackSpeed), + color = Color.White, modifier = modifier.clickable(onClick = { openDialog = true }), style = MaterialTheme.typography.labelLarge, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt index bafa1a06a..f8ca77825 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -76,6 +77,7 @@ fun PositionAndDurationText( Text( text = text, + color = Color.White, style = MaterialTheme.typography.labelLarge, modifier = modifier, ) 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..6b2f6df45 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,13 +21,19 @@ 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 +import com.vitorpamplona.amethyst.service.playback.composable.seekBackward +import com.vitorpamplona.amethyst.service.playback.composable.skipForward import kotlinx.coroutines.delay @OptIn(UnstableApi::class) @@ -36,16 +42,35 @@ fun RenderCenterButtons( controllerState: MediaControllerState, controllerVisible: MutableState, modifier: Modifier, + isLiveStream: Boolean = false, ) { val state = rememberPlayPauseButtonState(controllerState.controller) - 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) { + controllerState.controller.seekBackward() + } + } + + AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) { + state.onClick() + } + + if (!isLiveStream) { + AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) { + controllerState.controller.skipForward() + } + } } 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..bbf4ad8e4 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,16 +20,19 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import android.content.pm.PackageManager import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState @@ -52,9 +55,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 +76,26 @@ fun RenderTopButtons( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val context = LocalContext.current.getActivity() + val context = LocalContext.current + val isLive = isLiveStreaming(mediaData.videoUri) + val pipSupported = + remember { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } 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 { @@ -98,54 +109,31 @@ fun RenderTopButtons( ) } +@OptIn(ExperimentalPermissionsApi::class) @Composable fun RenderTopButtons( mediaData: MediaItemData, controllerVisible: MutableState, startingMuteState: Boolean, + isLive: Boolean, + pipSupported: Boolean, onMuteClick: (Boolean) -> Unit, onPictureInPictureClick: () -> Unit, onZoomClick: (() -> Unit)?, modifier: Modifier, accountViewModel: AccountViewModel, ) { - 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, - ) - } + val shareDialogVisible = remember { mutableStateOf(false) } + val saveAction = + rememberSaveMediaAction { context -> + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) } + LaunchedEffect(controllerVisible.value) { + if (!controllerVisible.value) shareDialogVisible.value = false + } + + Row(modifier) { if (onZoomClick != null) { FullScreenButton( controllerVisible = controllerVisible, @@ -153,15 +141,45 @@ fun RenderTopButtons( ) } - PictureInPictureButton( - controllerVisible = controllerVisible, - onClick = onPictureInPictureClick, - ) - MuteButton( controllerVisible = controllerVisible, startingMuteState = startingMuteState, toggle = onMuteClick, ) + + Box { + AnimatedOverflowMenuButton( + controllerVisible = controllerVisible, + showShare = true, + showSave = !isLive, + showPip = pipSupported, + onShareClick = { shareDialogVisible.value = true }, + onSaveClick = saveAction, + onPipClick = onPictureInPictureClick, + ) + + if (shareDialogVisible.value) { + 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/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt index 11e6111fc..5d46e1748 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt @@ -87,9 +87,36 @@ fun AnimatedSaveButton( } } +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun rememberSaveMediaAction(onSaveClick: (Context) -> Unit): () -> Unit { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val writeStoragePermission = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> + if (isGranted) onSaveClick(context) + } + return { + scope.launch { + Toast + .makeText( + context, + stringRes(context, R.string.video_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermission.status.isGranted) { + onSaveClick(context) + } else { + writeStoragePermission.launchPermissionRequest() + } + } +} + @OptIn(ExperimentalPermissionsApi::class) @Composable fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) { + val saveAction = rememberSaveMediaAction(onSaveClick) Box(modifier = PinBottomIconSize) { Box( Modifier @@ -98,35 +125,8 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) { .align(Alignment.Center) .background(MaterialTheme.colorScheme.background), ) - - val localContext = LocalContext.current - - val writeStoragePermissionState = - rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> - if (isGranted) { - onSaveClick(localContext) - } - } - val scope = rememberCoroutineScope() IconButton( - onClick = { - scope.launch { - Toast - .makeText( - localContext, - stringRes(localContext, R.string.video_download_has_started_toast), - Toast.LENGTH_SHORT, - ).show() - } - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - writeStoragePermissionState.status.isGranted - ) { - onSaveClick(localContext) - } else { - writeStoragePermissionState.launchPermissionRequest() - } - }, + onClick = saveAction, modifier = Size50Modifier, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt new file mode 100644 index 000000000..a30e6fa98 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +private val FadeIn = fadeIn() +private val FadeOut = fadeOut() + +@Preview +@Composable +fun SkipBackButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + SkipButton(isForward = false, onClick = {}) + } + } +} + +@Preview +@Composable +fun SkipForwardButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + SkipButton(isForward = true, onClick = {}) + } + } +} + +@Composable +fun AnimatedSkipButton( + controllerVisible: State, + isForward: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = FadeIn, + exit = FadeOut, + ) { + SkipButton(isForward = isForward, onClick = onClick) + } +} + +@Composable +fun SkipButton( + isForward: Boolean, + onClick: () -> Unit, +) { + val icon = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10 + val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS) + IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) { + Icon(imageVector = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(32.dp)) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7a30c3b25..e9355a967 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -917,6 +917,9 @@ Muted. Click to unmute Sound on. Click to mute + Skip back %d seconds + Skip forward %d seconds + Picture-in-Picture Search local and remote records Nostr address was verified