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-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 74c160de1..55ebb3815 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1157,6 +1157,7 @@ Připínáček Skenovat QR kód Přejít na poskytovatele peněženky třetí strany Alby + Vyberte datum Není možné odpovědět na koncept Není možné citovat koncept Není možné reagovat na koncept diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index c9472da64..499ac2d44 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1162,6 +1162,7 @@ anz der Bedingungen ist erforderlich Reißzwecke QR-Code scannen Navigieren Sie zum Drittanbieter-Wallet-Anbieter Alby + Datum auswählen Es ist nicht möglich, auf einen Entwurf zu antworten Es ist nicht möglich, einen Entwurf zu zitieren Es ist nicht möglich, auf einen Entwurf zu reagieren diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index aa3b4e321..3eda2b3a6 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1157,6 +1157,7 @@ Piones Escanear código QR Navegar para o provedor de carteira de terceiros Alby + Selecionar data Não é possível responder uma nota em rascunho Não é possível citar uma nota em rascunho Não é possível reagir uma nota em rascunho diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 4e5686b9d..0a84bf741 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -701,6 +701,17 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Določila & Pogoji N/A Napake in obvestila s tega releja + Aktivne naročnine + Izhodni dogodki v teku + REQ Naročnine (%1$d) + COUNT Naročnine (%1$d) + Izhodni dogodki (%1$d) + Ni aktivne naročnine za ta rele + %1$d avtorjev + %1$d ids + od %1$s + do %1$s + limit %1$d Dolžina sporočila Naročnine Filtri @@ -760,9 +771,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ta skupnost nima opisa. Obrnite se na lastnika, da ga doda Občutljiva vsebina Doda opozorilo o občutljivi vsebini pred prikazom te vsebine + UI Možnosti Nastavitve aplikacije Nastavitve uporabnika + Prevodi + Reakcije Nastavitve + Nastavitve računa + Nastavitve aplikacije Vedno Samo Wifi Samo z WiFi @@ -1031,13 +1047,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Novo zapisek skupnosti Nov produkt Nova geo-ekskluzivna objava - Odpri vse odzive na to objavo - Zapri vse odzive na to objavo + Odpri vse reakcije na to objavo + Zapri vse reakcije na to objavo Odgovori Posreduj ali citiraj Všečkaj Zap - Spremeni hitre odzivne ikone + Spremeni hitre reakcije Slika profila %1$s Rele %1$s Razširi seznam relejev @@ -1110,6 +1126,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zaupanja vredni releji Zaupanja vredni releji Releji katerim zaupaš in zanje ne rabiš Tor povezave + Najljubše vsebine releja + Najljubše vsebine releja + Releji katerih pogosto spremljaš globalne vsebine Proxy releji Proxy releji Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama. @@ -1155,6 +1174,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pushpin Skeniraj QR kodo Pojdite na stran tretjega ponudnika denarnice Alby + Izberi datum Ni mogoče odgovoriti na osnutek zapiska Ni mogoče citirati osnutka zapiska Ni mogoče reagirati na osnutek zapiska @@ -1291,4 +1311,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Potrdi Naprej Dodaj gumb za glasovanje + Nazaj + Dovoli + Povezano + Družbeno dokazilo + Oddaj + Ponovno zaženi + Sprejmi + Zavrni + Nova igra + Nova partija šaha + Nalagam partijo\u2026 + Partija ni najdena + Ta partija je zaključena ali pa, čaka na nasprotnika + ID Partije: %1$s\u2026 + in %1$d ostalih + Odstrani %1$s diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 789efa01f..67be922f1 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1156,6 +1156,7 @@ Häftstift Skanna QR-kod Navigera till tredjeparts plånboksleverantören Alby + Välj datum Det går inte att svara på ett utkast Det går inte att citera ett utkast Det går inte att reagera på ett utkast 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