From eb57811654d871cae34370bdee8e5b4051b59bde Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 26 Feb 2026 18:13:34 +0000 Subject: [PATCH] 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