From eb57811654d871cae34370bdee8e5b4051b59bde Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:13:34 +0000 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 c335d3acd475768cdcc37351ba919732fe26dcc6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 2 Mar 2026 21:31:39 +0000 Subject: [PATCH 9/9] 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, )