From eb57811654d871cae34370bdee8e5b4051b59bde Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:13:34 +0000 Subject: [PATCH 01/10] add skip button component, overflow menu component, gradient overlay --- .../composable/controls/GradientOverlay.kt | 99 ++++++++ .../composable/controls/OverflowMenu.kt | 218 ++++++++++++++++++ .../composable/controls/SkipButton.kt | 124 ++++++++++ amethyst/src/main/res/values/strings.xml | 6 + 4 files changed, 447 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt 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..18f26df4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt @@ -0,0 +1,99 @@ +/* + * 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.runtime.remember +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 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 +fun TopGradientOverlay( + controllerVisible: State, + modifier: Modifier = Modifier, + height: Dp = 80.dp, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(height) + .background( + brush = Brush.verticalGradient(colors = TopGradientColors), + ), + ) + } +} + +@Composable +fun BottomGradientOverlay( + controllerVisible: State, + modifier: Modifier = Modifier, + height: Dp = 120.dp, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(height) + .background( + brush = Brush.verticalGradient(colors = BottomGradientColors), + ), + ) + } +} 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..79ce2cbb2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -0,0 +1,218 @@ +/* + * 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.Fullscreen +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 + +@Preview +@Composable +fun OverflowMenuButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + OverflowMenuButton( + showShare = true, + showSave = true, + showFullscreen = true, + showPip = true, + onShareClick = {}, + onSaveClick = {}, + onFullscreenClick = {}, + onPipClick = {}, + ) + } + } +} + +@Composable +fun AnimatedOverflowMenuButton( + controllerVisible: State, + modifier: Modifier = Modifier, + showShare: Boolean = true, + showSave: Boolean = true, + showFullscreen: Boolean = false, + showPip: Boolean = false, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, + onFullscreenClick: () -> Unit, + onPipClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + OverflowMenuButton( + showShare = showShare, + showSave = showSave, + showFullscreen = showFullscreen, + showPip = showPip, + onShareClick = onShareClick, + onSaveClick = onSaveClick, + onFullscreenClick = onFullscreenClick, + onPipClick = onPipClick, + ) + } +} + +@Composable +fun OverflowMenuButton( + showShare: Boolean, + showSave: Boolean, + showFullscreen: Boolean, + showPip: Boolean, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, + onFullscreenClick: () -> 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 (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) }, + 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/SkipButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt new file mode 100644 index 000000000..65bc39ead --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt @@ -0,0 +1,124 @@ +/* + * 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.runtime.remember +import androidx.compose.ui.Alignment +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.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun SkipBackButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + SkipButton( + isForward = false, + skipSeconds = 10, + onClick = {}, + ) + } + } +} + +@Preview +@Composable +fun SkipForwardButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + SkipButton( + isForward = true, + skipSeconds = 10, + onClick = {}, + ) + } + } +} + +@Composable +fun AnimatedSkipButton( + controllerVisible: State, + isForward: Boolean, + skipSeconds: Int = 10, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + SkipButton( + isForward = isForward, + skipSeconds = skipSeconds, + onClick = onClick, + ) + } +} + +@Composable +fun SkipButton( + isForward: Boolean, + skipSeconds: Int = 10, + onClick: () -> Unit, +) { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = onClick, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10, + contentDescription = + if (isForward) { + stringRes(R.string.skip_forward, skipSeconds) + } else { + stringRes(R.string.skip_back, skipSeconds) + }, + 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 04cebd1bf..da243bd44 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -901,6 +901,12 @@ Muted. Click to unmute Sound on. Click to mute + Skip back %d seconds + Skip forward %d seconds + Fullscreen + Picture-in-Picture + LIVE + Tap to retry Search local and remote records Nostr address was verified From 3e9475087e62da19bcc6560c97fbe8e67b06ce1c Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:17:13 +0000 Subject: [PATCH 02/10] simplify top buttons, move to overflow add skip buttons add gradient overlays and double-tap gesture support --- .../playback/composable/RenderVideoPlayer.kt | 67 +++++++++-- .../HorizontalLinearProgressIndicator.kt | 8 +- .../composable/controls/OverflowMenu.kt | 26 ----- .../controls/RenderCenterButtons.kt | 42 ++++++- .../composable/controls/RenderTopButtons.kt | 105 ++++++++++-------- amethyst/src/main/res/values/strings.xml | 1 - 6 files changed, 158 insertions(+), 91 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 2de1333a4..7ae36d398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -21,8 +21,7 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.annotation.OptIn -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -31,16 +30,22 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW +import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons +import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform +import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel private fun getVideoSizeDp(player: Player): Size? { @@ -71,21 +76,57 @@ fun RenderVideoPlayer( controllerVisible: MutableState = remember { mutableStateOf(false) }, accountViewModel: AccountViewModel, ) { - Box(modifier = borderModifier) { + val containerSize = remember { mutableStateOf(IntSize.Zero) } + val isLive = isLiveStreaming(mediaItem.src.videoUri) + val skipSeconds = if (controllerState.controller.duration in 1..30000) 5 else 10 + + Box( + modifier = + borderModifier + .onSizeChanged { containerSize.value = it } + .pointerInput(Unit) { + detectTapGestures( + onTap = { controllerVisible.value = !controllerVisible.value }, + onDoubleTap = { offset -> + if (!isLive) { + val isLeftSide = offset.x < containerSize.value.width / 2 + if (isLeftSide) { + val newPosition = + (controllerState.controller.currentPosition - skipSeconds * 1000) + .coerceAtLeast(0) + controllerState.controller.seekTo(newPosition) + } else { + val duration = controllerState.controller.duration + val newPosition = + (controllerState.controller.currentPosition + skipSeconds * 1000) + .coerceAtMost(duration) + controllerState.controller.seekTo(newPosition) + } + } + }, + ) + }, + ) { ContentFrame( player = controllerState.controller, - modifier = - videoModifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, // to prevent the ripple from the tap - ) { controllerVisible.value = !controllerVisible.value }, - surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts. + modifier = videoModifier, + surfaceType = SURFACE_TYPE_TEXTURE_VIEW, contentScale = contentScale, ) mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) } if (showControls) { + TopGradientOverlay( + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.TopCenter), + ) + + BottomGradientOverlay( + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.BottomCenter), + ) + RenderTopButtons( mediaData = mediaItem.src, controllerState = controllerState, @@ -95,7 +136,13 @@ fun RenderVideoPlayer( accountViewModel = accountViewModel, ) - RenderCenterButtons(controllerState, controllerVisible, Modifier.align(Alignment.Center)) + RenderCenterButtons( + controllerState = controllerState, + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.Center), + isLiveStream = isLive, + videoDurationMs = controllerState.controller.duration, + ) RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt index 44b0ec8d1..88628f69a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt @@ -98,10 +98,10 @@ private fun HorizontalLinearProgressIndicator( onLayoutWidthChanged: (Int) -> Unit = {}, onSeek: (Float) -> Unit, playedColor: Color = Color.White, - bufferedColor: Color = Color.LightGray, - unplayedColor: Color = Color.DarkGray, + bufferedColor: Color = Color.White.copy(alpha = 0.5f), + unplayedColor: Color = Color.White.copy(alpha = 0.3f), scrubberColor: Color = playedColor, - rectHeightDp: Dp = 3.dp, + rectHeightDp: Dp = 4.dp, ) { Canvas( Modifier @@ -125,7 +125,7 @@ private fun HorizontalLinearProgressIndicator( drawCircle( color = scrubberColor, - radius = size.height * 1.5f, + radius = size.height * 2f, center = Offset(x = positionX, y = size.height / 2.0f), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index 79ce2cbb2..f656c60bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.SaveAlt @@ -64,11 +63,9 @@ fun OverflowMenuButtonPreview() { OverflowMenuButton( showShare = true, showSave = true, - showFullscreen = true, showPip = true, onShareClick = {}, onSaveClick = {}, - onFullscreenClick = {}, onPipClick = {}, ) } @@ -81,11 +78,9 @@ fun AnimatedOverflowMenuButton( modifier: Modifier = Modifier, showShare: Boolean = true, showSave: Boolean = true, - showFullscreen: Boolean = false, showPip: Boolean = false, onShareClick: () -> Unit, onSaveClick: () -> Unit, - onFullscreenClick: () -> Unit, onPipClick: () -> Unit, ) { AnimatedVisibility( @@ -97,11 +92,9 @@ fun AnimatedOverflowMenuButton( OverflowMenuButton( showShare = showShare, showSave = showSave, - showFullscreen = showFullscreen, showPip = showPip, onShareClick = onShareClick, onSaveClick = onSaveClick, - onFullscreenClick = onFullscreenClick, onPipClick = onPipClick, ) } @@ -111,11 +104,9 @@ fun AnimatedOverflowMenuButton( fun OverflowMenuButton( showShare: Boolean, showSave: Boolean, - showFullscreen: Boolean, showPip: Boolean, onShareClick: () -> Unit, onSaveClick: () -> Unit, - onFullscreenClick: () -> Unit, onPipClick: () -> Unit, ) { val menuExpanded = remember { mutableStateOf(false) } @@ -180,23 +171,6 @@ fun OverflowMenuButton( ) } - if (showFullscreen) { - DropdownMenuItem( - text = { Text(stringRes(R.string.fullscreen), color = Color.White) }, - onClick = { - menuExpanded.value = false - onFullscreenClick() - }, - leadingIcon = { - Icon( - Icons.Default.Fullscreen, - contentDescription = null, - tint = Color.White, - ) - }, - ) - } - if (showPip) { DropdownMenuItem( text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt index 45c554f35..182e446bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -21,10 +21,14 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState @@ -36,16 +40,48 @@ fun RenderCenterButtons( controllerState: MediaControllerState, controllerVisible: MutableState, modifier: Modifier, + isLiveStream: Boolean = false, + videoDurationMs: Long = 0L, ) { val state = rememberPlayPauseButtonState(controllerState.controller) + val skipSeconds = if (videoDurationMs in 1..30000) 5 else 10 - AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) { - state.onClick() + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isLiveStream) { + AnimatedSkipButton( + controllerVisible = controllerVisible, + isForward = false, + skipSeconds = skipSeconds, + ) { + val newPosition = (controllerState.controller.currentPosition - skipSeconds * 1000).coerceAtLeast(0) + controllerState.controller.seekTo(newPosition) + } + } + + AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) { + state.onClick() + } + + if (!isLiveStream) { + AnimatedSkipButton( + controllerVisible = controllerVisible, + isForward = true, + skipSeconds = skipSeconds, + ) { + val duration = controllerState.controller.duration + val newPosition = (controllerState.controller.currentPosition + skipSeconds * 1000).coerceAtMost(duration) + controllerState.controller.seekTo(newPosition) + } + } } if (!state.showPlay) { LaunchedEffect(state.showPlay) { - delay(2000) + delay(3000) controllerVisible.value = false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index e103ec1e4..bb26bd0d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import android.content.pm.PackageManager +import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -52,9 +54,11 @@ fun RenderTopButtonsPreview() { mediaData = MediaItemData("http://test.mp4"), controllerVisible = remember { mutableStateOf(true) }, startingMuteState = false, - onMuteClick = { }, - onPictureInPictureClick = { }, - onZoomClick = { }, + isLive = false, + pipSupported = true, + onMuteClick = {}, + onPictureInPictureClick = {}, + onZoomClick = {}, modifier = Modifier, accountViewModel = mockAccountViewModel(), ) @@ -71,20 +75,30 @@ fun RenderTopButtons( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val context = LocalContext.current.getActivity() + val context = LocalContext.current + val isLive = isLiveStreaming(mediaData.videoUri) + val pipSupported = + remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } else { + false + } + } RenderTopButtons( mediaData = mediaData, controllerVisible = controllerVisible, startingMuteState = controllerState.controller.volume < 0.001, + isLive = isLive, + pipSupported = pipSupported, onMuteClick = { mute -> - // makes the new setting the default for new creations. DEFAULT_MUTED_SETTING.value = mute controllerState.controller.volume = if (mute) 0f else 1f }, onPictureInPictureClick = { controllerState.controller.pause() - PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context) + PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity()) }, onZoomClick = onZoomClick?.let { @@ -103,49 +117,18 @@ fun RenderTopButtons( mediaData: MediaItemData, controllerVisible: MutableState, startingMuteState: Boolean, + isLive: Boolean, + pipSupported: Boolean, onMuteClick: (Boolean) -> Unit, onPictureInPictureClick: () -> Unit, onZoomClick: (() -> Unit)?, modifier: Modifier, accountViewModel: AccountViewModel, ) { + val context = LocalContext.current + val shareDialogVisible = remember { mutableStateOf(false) } + Row(modifier) { - if (!isLiveStreaming(mediaData.videoUri)) { - AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> - ShareMediaAction( - popupExpanded = popupExpanded, - videoUri = mediaData.videoUri, - postNostrUri = mediaData.callbackUri, - blurhash = null, - dim = null, - hash = null, - mimeType = mediaData.mimeType, - onDismiss = toggle, - content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), - accountViewModel = accountViewModel, - ) - } - - AnimatedSaveButton(controllerVisible) { context -> - accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) - } - } else { - AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> - ShareMediaAction( - popupExpanded = popupExpanded, - videoUri = mediaData.videoUri, - postNostrUri = mediaData.callbackUri, - blurhash = null, - dim = null, - hash = null, - mimeType = mediaData.mimeType, - onDismiss = toggle, - content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), - accountViewModel = accountViewModel, - ) - } - } - if (onZoomClick != null) { FullScreenButton( controllerVisible = controllerVisible, @@ -153,15 +136,43 @@ fun RenderTopButtons( ) } - PictureInPictureButton( - controllerVisible = controllerVisible, - onClick = onPictureInPictureClick, - ) - MuteButton( controllerVisible = controllerVisible, startingMuteState = startingMuteState, toggle = onMuteClick, ) + + AnimatedOverflowMenuButton( + controllerVisible = controllerVisible, + showShare = true, + showSave = !isLive, + showPip = pipSupported, + onShareClick = { shareDialogVisible.value = true }, + onSaveClick = { + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + }, + onPipClick = onPictureInPictureClick, + ) } + + ShareMediaAction( + popupExpanded = shareDialogVisible, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = { shareDialogVisible.value = false }, + content = + MediaUrlVideo( + url = mediaData.videoUri, + mimeType = mediaData.mimeType, + artworkUri = mediaData.artworkUri, + authorName = mediaData.authorName, + description = mediaData.title, + uri = mediaData.callbackUri, + ), + accountViewModel = accountViewModel, + ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index da243bd44..e98d1c14e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -903,7 +903,6 @@ Sound on. Click to mute Skip back %d seconds Skip forward %d seconds - Fullscreen Picture-in-Picture LIVE Tap to retry From ebbc7b5526b0855e61eb0d2c277f3bdb3e4dd0af Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:21:43 +0000 Subject: [PATCH 03/10] Code review fixes: skipSeconds duplication + stale duration read pointerInput(Unit) stale capture ShareMediaAction composed unconditionally Overflow menu background opaque in dark theme --- .../playback/composable/RenderVideoPlayer.kt | 6 ++- .../composable/controls/OverflowMenu.kt | 2 +- .../controls/RenderCenterButtons.kt | 3 +- .../composable/controls/RenderTopButtons.kt | 49 +++++++++---------- 4 files changed, 30 insertions(+), 30 deletions(-) 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 7ae36d398..7476629e3 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 @@ -48,6 +48,8 @@ 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 fun computeSkipSeconds(durationMs: Long): Int = if (durationMs in 1..30000) 5 else 10 + private fun getVideoSizeDp(player: Player): Size? { var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat()) @@ -78,17 +80,17 @@ fun RenderVideoPlayer( ) { 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) { + .pointerInput(isLive, controllerState) { detectTapGestures( onTap = { controllerVisible.value = !controllerVisible.value }, onDoubleTap = { offset -> if (!isLive) { + val skipSeconds = computeSkipSeconds(controllerState.controller.duration) val isLeftSide = offset.x < containerSize.value.width / 2 if (isLeftSide) { val newPosition = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index f656c60bd..f3d18eec0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -117,7 +117,7 @@ fun OverflowMenuButton( .clip(CircleShape) .fillMaxSize(0.7f) .align(Alignment.Center) - .background(MaterialTheme.colorScheme.background), + .background(Color.Black.copy(alpha = 0.4f)), ) IconButton( 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 182e446bc..085381d4c 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 @@ -32,6 +32,7 @@ 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.computeSkipSeconds import kotlinx.coroutines.delay @OptIn(UnstableApi::class) @@ -44,7 +45,7 @@ fun RenderCenterButtons( videoDurationMs: Long = 0L, ) { val state = rememberPlayPauseButtonState(controllerState.controller) - val skipSeconds = if (videoDurationMs in 1..30000) 5 else 10 + val skipSeconds = computeSkipSeconds(videoDurationMs) Row( modifier = modifier, 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 bb26bd0d9..6010bb47a 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 @@ -21,7 +21,6 @@ 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 @@ -79,11 +78,7 @@ fun RenderTopButtons( 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 - } + context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } RenderTopButtons( @@ -155,24 +150,26 @@ fun RenderTopButtons( ) } - 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, - ) + 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, + ) + } } From ce76f8fb27536e2656fff6bc08afe6a543c28888 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:22:11 +0000 Subject: [PATCH 04/10] Code review fixes: Redundant Box in SkipButton Unused string resources remember { fadeIn() } pattern --- .../composable/controls/GradientOverlay.kt | 12 +++--- .../composable/controls/OverflowMenu.kt | 7 +++- .../composable/controls/SkipButton.kt | 40 +++++++++---------- amethyst/src/main/res/values/strings.xml | 4 +- 4 files changed, 31 insertions(+), 32 deletions(-) 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 index 18f26df4c..9b54413e2 100644 --- 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 @@ -29,13 +29,15 @@ 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.runtime.remember 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), @@ -59,8 +61,8 @@ fun TopGradientOverlay( AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = FadeIn, + exit = FadeOut, ) { Box( modifier = @@ -83,8 +85,8 @@ fun BottomGradientOverlay( AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = FadeIn, + exit = FadeOut, ) { Box( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index f3d18eec0..fe9b09abc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -55,6 +55,9 @@ 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() { @@ -86,8 +89,8 @@ fun AnimatedOverflowMenuButton( AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = FadeIn, + exit = FadeOut, ) { OverflowMenuButton( showShare = showShare, 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 index 65bc39ead..2d37909e5 100644 --- 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 @@ -33,8 +33,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.State -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview @@ -44,6 +42,9 @@ 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() { @@ -83,8 +84,8 @@ fun AnimatedSkipButton( AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, + enter = FadeIn, + exit = FadeOut, ) { SkipButton( isForward = isForward, @@ -100,25 +101,20 @@ fun SkipButton( skipSeconds: Int = 10, onClick: () -> Unit, ) { - Box( + IconButton( + onClick = onClick, modifier = Modifier.size(48.dp), - contentAlignment = Alignment.Center, ) { - IconButton( - onClick = onClick, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10, - contentDescription = - if (isForward) { - stringRes(R.string.skip_forward, skipSeconds) - } else { - stringRes(R.string.skip_back, skipSeconds) - }, - tint = Color.White, - modifier = Modifier.size(32.dp), - ) - } + Icon( + imageVector = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10, + contentDescription = + if (isForward) { + stringRes(R.string.skip_forward, skipSeconds) + } else { + stringRes(R.string.skip_back, skipSeconds) + }, + 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 e98d1c14e..b6134b087 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -904,8 +904,6 @@ Skip back %d seconds Skip forward %d seconds Picture-in-Picture - LIVE - Tap to retry Search local and remote records Nostr address was verified @@ -1505,7 +1503,7 @@ %1$d/%2$d Broadcasting Broadcasting %1$s - Broadcasting %1$d events... + Broadcasting %1$d events… %1$d events sent Some events failed From 3822197830faf5537088a1af6f2d327420ba42ab Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:22:28 +0000 Subject: [PATCH 05/10] Code review fixes: Skip-forward clamps against unknown duration Share dropdown is no longer anchored to the overflow button simplify: keep skip amount fixed at 10 --- .../playback/composable/RenderVideoPlayer.kt | 15 ++-- .../controls/RenderCenterButtons.kt | 12 ++-- .../composable/controls/RenderTopButtons.kt | 71 ++++++++++--------- .../composable/controls/SkipButton.kt | 25 ++----- 4 files changed, 56 insertions(+), 67 deletions(-) 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 7476629e3..be94e52bd 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 @@ -48,7 +48,8 @@ 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 fun computeSkipSeconds(durationMs: Long): Int = if (durationMs in 1..30000) 5 else 10 +internal const val SKIP_SECONDS = 10 +internal const val SKIP_MILLIS = SKIP_SECONDS * 1000L private fun getVideoSizeDp(player: Player): Size? { var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat()) @@ -90,19 +91,18 @@ fun RenderVideoPlayer( onTap = { controllerVisible.value = !controllerVisible.value }, onDoubleTap = { offset -> if (!isLive) { - val skipSeconds = computeSkipSeconds(controllerState.controller.duration) val isLeftSide = offset.x < containerSize.value.width / 2 if (isLeftSide) { val newPosition = - (controllerState.controller.currentPosition - skipSeconds * 1000) + (controllerState.controller.currentPosition - SKIP_MILLIS) .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) + val newPosition = controllerState.controller.currentPosition + SKIP_MILLIS + controllerState.controller.seekTo( + if (duration > 0) newPosition.coerceAtMost(duration) else newPosition, + ) } } }, @@ -143,7 +143,6 @@ fun RenderVideoPlayer( controllerVisible = controllerVisible, modifier = Modifier.align(Alignment.Center), isLiveStream = isLive, - videoDurationMs = controllerState.controller.duration, ) RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt index 085381d4c..f2e5a6dbf 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 @@ -32,7 +32,7 @@ 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.computeSkipSeconds +import com.vitorpamplona.amethyst.service.playback.composable.SKIP_MILLIS import kotlinx.coroutines.delay @OptIn(UnstableApi::class) @@ -42,10 +42,8 @@ fun RenderCenterButtons( controllerVisible: MutableState, modifier: Modifier, isLiveStream: Boolean = false, - videoDurationMs: Long = 0L, ) { val state = rememberPlayPauseButtonState(controllerState.controller) - val skipSeconds = computeSkipSeconds(videoDurationMs) Row( modifier = modifier, @@ -56,9 +54,8 @@ fun RenderCenterButtons( AnimatedSkipButton( controllerVisible = controllerVisible, isForward = false, - skipSeconds = skipSeconds, ) { - val newPosition = (controllerState.controller.currentPosition - skipSeconds * 1000).coerceAtLeast(0) + val newPosition = (controllerState.controller.currentPosition - SKIP_MILLIS).coerceAtLeast(0) controllerState.controller.seekTo(newPosition) } } @@ -71,11 +68,10 @@ fun RenderCenterButtons( 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) + val newPosition = controllerState.controller.currentPosition + SKIP_MILLIS + controllerState.controller.seekTo(if (duration > 0) newPosition.coerceAtMost(duration) else newPosition) } } } 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 6010bb47a..4ae27026c 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 @@ -25,6 +25,7 @@ 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 @@ -123,6 +124,10 @@ fun RenderTopButtons( val context = LocalContext.current val shareDialogVisible = remember { mutableStateOf(false) } + LaunchedEffect(controllerVisible.value) { + if (!controllerVisible.value) shareDialogVisible.value = false + } + Row(modifier) { if (onZoomClick != null) { FullScreenButton( @@ -137,39 +142,41 @@ fun RenderTopButtons( 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, - ) - } + Box { + AnimatedOverflowMenuButton( + controllerVisible = controllerVisible, + showShare = true, + showSave = !isLive, + showPip = pipSupported, + onShareClick = { shareDialogVisible.value = true }, + onSaveClick = { + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + }, + 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, + if (shareDialogVisible.value) { + ShareMediaAction( + popupExpanded = shareDialogVisible, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, mimeType = mediaData.mimeType, - artworkUri = mediaData.artworkUri, - authorName = mediaData.authorName, - description = mediaData.title, - uri = mediaData.callbackUri, - ), - accountViewModel = accountViewModel, - ) + 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/SkipButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt index 2d37909e5..b7776ac32 100644 --- 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 @@ -38,6 +38,7 @@ 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 @@ -50,11 +51,7 @@ private val FadeOut = fadeOut() fun SkipBackButtonPreview() { ThemeComparisonColumn { Box(Modifier.background(BitcoinOrange)) { - SkipButton( - isForward = false, - skipSeconds = 10, - onClick = {}, - ) + SkipButton(isForward = false, onClick = {}) } } } @@ -64,11 +61,7 @@ fun SkipBackButtonPreview() { fun SkipForwardButtonPreview() { ThemeComparisonColumn { Box(Modifier.background(BitcoinOrange)) { - SkipButton( - isForward = true, - skipSeconds = 10, - onClick = {}, - ) + SkipButton(isForward = true, onClick = {}) } } } @@ -77,7 +70,6 @@ fun SkipForwardButtonPreview() { fun AnimatedSkipButton( controllerVisible: State, isForward: Boolean, - skipSeconds: Int = 10, modifier: Modifier = Modifier, onClick: () -> Unit, ) { @@ -87,18 +79,13 @@ fun AnimatedSkipButton( enter = FadeIn, exit = FadeOut, ) { - SkipButton( - isForward = isForward, - skipSeconds = skipSeconds, - onClick = onClick, - ) + SkipButton(isForward = isForward, onClick = onClick) } } @Composable fun SkipButton( isForward: Boolean, - skipSeconds: Int = 10, onClick: () -> Unit, ) { IconButton( @@ -109,9 +96,9 @@ fun SkipButton( imageVector = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10, contentDescription = if (isForward) { - stringRes(R.string.skip_forward, skipSeconds) + stringRes(R.string.skip_forward, SKIP_SECONDS) } else { - stringRes(R.string.skip_back, skipSeconds) + stringRes(R.string.skip_back, SKIP_SECONDS) }, tint = Color.White, modifier = Modifier.size(32.dp), From 87bf52b59e475e01f7e7c8c721e641def6e8712f Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:22:52 +0000 Subject: [PATCH 06/10] Code review fixes: reduce duplicate code --- .../playback/composable/RenderVideoPlayer.kt | 20 ++++++----- .../composable/controls/GradientOverlay.kt | 35 +++++++------------ .../controls/RenderCenterButtons.kt | 20 ++++------- .../composable/controls/SkipButton.kt | 19 +++------- 4 files changed, 33 insertions(+), 61 deletions(-) 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 be94e52bd..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 @@ -51,6 +51,15 @@ 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()) @@ -93,16 +102,9 @@ fun RenderVideoPlayer( if (!isLive) { val isLeftSide = offset.x < containerSize.value.width / 2 if (isLeftSide) { - val newPosition = - (controllerState.controller.currentPosition - SKIP_MILLIS) - .coerceAtLeast(0) - controllerState.controller.seekTo(newPosition) + controllerState.controller.seekBackward() } else { - val duration = controllerState.controller.duration - val newPosition = controllerState.controller.currentPosition + SKIP_MILLIS - controllerState.controller.seekTo( - if (duration > 0) newPosition.coerceAtMost(duration) else newPosition, - ) + controllerState.controller.skipForward() } } }, 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 index 9b54413e2..938aa8ef9 100644 --- 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 @@ -53,10 +53,11 @@ private val BottomGradientColors = ) @Composable -fun TopGradientOverlay( +private fun GradientOverlay( controllerVisible: State, + colors: List, + height: Dp, modifier: Modifier = Modifier, - height: Dp = 80.dp, ) { AnimatedVisibility( visible = controllerVisible.value, @@ -69,33 +70,21 @@ fun TopGradientOverlay( Modifier .fillMaxWidth() .height(height) - .background( - brush = Brush.verticalGradient(colors = TopGradientColors), - ), + .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, -) { - AnimatedVisibility( - visible = controllerVisible.value, - modifier = modifier, - enter = FadeIn, - exit = FadeOut, - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(height) - .background( - brush = Brush.verticalGradient(colors = BottomGradientColors), - ), - ) - } -} +) = GradientOverlay(controllerVisible, BottomGradientColors, height, 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 f2e5a6dbf..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 @@ -32,7 +32,8 @@ 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.SKIP_MILLIS +import com.vitorpamplona.amethyst.service.playback.composable.seekBackward +import com.vitorpamplona.amethyst.service.playback.composable.skipForward import kotlinx.coroutines.delay @OptIn(UnstableApi::class) @@ -51,12 +52,8 @@ fun RenderCenterButtons( verticalAlignment = Alignment.CenterVertically, ) { if (!isLiveStream) { - AnimatedSkipButton( - controllerVisible = controllerVisible, - isForward = false, - ) { - val newPosition = (controllerState.controller.currentPosition - SKIP_MILLIS).coerceAtLeast(0) - controllerState.controller.seekTo(newPosition) + AnimatedSkipButton(controllerVisible = controllerVisible, isForward = false) { + controllerState.controller.seekBackward() } } @@ -65,13 +62,8 @@ fun RenderCenterButtons( } if (!isLiveStream) { - AnimatedSkipButton( - controllerVisible = controllerVisible, - isForward = true, - ) { - val duration = controllerState.controller.duration - val newPosition = controllerState.controller.currentPosition + SKIP_MILLIS - controllerState.controller.seekTo(if (duration > 0) newPosition.coerceAtMost(duration) else newPosition) + AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) { + controllerState.controller.skipForward() } } } 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 index b7776ac32..a30e6fa98 100644 --- 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 @@ -88,20 +88,9 @@ fun SkipButton( isForward: Boolean, onClick: () -> Unit, ) { - IconButton( - onClick = onClick, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10, - contentDescription = - if (isForward) { - stringRes(R.string.skip_forward, SKIP_SECONDS) - } else { - stringRes(R.string.skip_back, SKIP_SECONDS) - }, - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + 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)) } } From 08f389230e5fcb86222f93275c111971eb0a185a Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 2 Mar 2026 18:00:58 +0000 Subject: [PATCH 07/10] restore download toast and permission check fix overflow button background to solid --- .../composable/controls/OverflowMenu.kt | 2 +- .../composable/controls/RenderTopButtons.kt | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index fe9b09abc..491634b68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -120,7 +120,7 @@ fun OverflowMenuButton( .clip(CircleShape) .fillMaxSize(0.7f) .align(Alignment.Center) - .background(Color.Black.copy(alpha = 0.4f)), + .background(MaterialTheme.colorScheme.background), ) IconButton( 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 4ae27026c..e4e19839f 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,7 +20,10 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import android.Manifest import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -29,9 +32,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.R 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 @@ -42,8 +50,10 @@ import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.coroutines.launch @Preview @Composable @@ -108,6 +118,7 @@ fun RenderTopButtons( ) } +@OptIn(ExperimentalPermissionsApi::class) @Composable fun RenderTopButtons( mediaData: MediaItemData, @@ -122,8 +133,14 @@ fun RenderTopButtons( accountViewModel: AccountViewModel, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val shareDialogVisible = remember { mutableStateOf(false) } + val writeStoragePermission = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { + if (it) accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + } + LaunchedEffect(controllerVisible.value) { if (!controllerVisible.value) shareDialogVisible.value = false } @@ -150,7 +167,19 @@ fun RenderTopButtons( showPip = pipSupported, onShareClick = { shareDialogVisible.value = true }, onSaveClick = { - accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + 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) { + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + } else { + writeStoragePermission.launchPermissionRequest() + } }, onPipClick = onPictureInPictureClick, ) From 3220b4a1a7f8ea08954632aa64c30d1ef66bc1e9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 2 Mar 2026 18:07:17 +0000 Subject: [PATCH 08/10] extract rememberSaveMediaAction to eliminate toast/permission duplication --- .../composable/controls/RenderTopButtons.kt | 34 ++--------- .../composable/controls/SaveMediaButton.kt | 56 +++++++++---------- 2 files changed, 32 insertions(+), 58 deletions(-) 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 e4e19839f..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,10 +20,7 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls -import android.Manifest import android.content.pm.PackageManager -import android.os.Build -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -32,14 +29,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope 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.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.R 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 @@ -50,10 +43,8 @@ import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import kotlinx.coroutines.launch @Preview @Composable @@ -132,13 +123,10 @@ fun RenderTopButtons( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() val shareDialogVisible = remember { mutableStateOf(false) } - - val writeStoragePermission = - rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { - if (it) accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + val saveAction = + rememberSaveMediaAction { context -> + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) } LaunchedEffect(controllerVisible.value) { @@ -166,21 +154,7 @@ fun RenderTopButtons( showSave = !isLive, showPip = pipSupported, onShareClick = { shareDialogVisible.value = true }, - onSaveClick = { - 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) { - accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) - } else { - writeStoragePermission.launchPermissionRequest() - } - }, + onSaveClick = saveAction, onPipClick = onPictureInPictureClick, ) 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( From db380cfe24c89c33a6c7836031d3ae6c4c6ccda3 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 2 Mar 2026 20:58:37 +0000 Subject: [PATCH 09/10] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 1 + .../src/main/res/values-de-rDE/strings.xml | 1 + .../src/main/res/values-pt-rBR/strings.xml | 1 + .../src/main/res/values-sl-rSI/strings.xml | 42 +++++++++++++++++-- .../src/main/res/values-sv-rSE/strings.xml | 1 + 5 files changed, 43 insertions(+), 3 deletions(-) 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 From c335d3acd475768cdcc37351ba919732fe26dcc6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 2 Mar 2026 21:31:39 +0000 Subject: [PATCH 10/10] always show playback speed and position/duration text in white regardless of theme --- .../playback/composable/controls/PlaybackSpeedPopUpButton.kt | 2 ++ .../playback/composable/controls/PositionAndDurationText.kt | 2 ++ 2 files changed, 4 insertions(+) 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, )