Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: always show playback speed and position/duration text in white regardless of theme New Crowdin translations by GitHub Action extract rememberSaveMediaAction to eliminate toast/permission duplication restore download toast and permission check fix overflow button background to solid Code review fixes: reduce duplicate code 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 Code review fixes: Redundant Box in SkipButton Unused string resources remember { fadeIn() } pattern Code review fixes: skipSeconds duplication + stale duration read pointerInput(Unit) stale capture ShareMediaAction composed unconditionally Overflow menu background opaque in dark theme simplify top buttons, move to overflow add skip buttons add gradient overlays and double-tap gesture support add skip button component, overflow menu component, gradient overlay
This commit is contained in:
+60
-10
@@ -21,8 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.service.playback.composable
|
package com.vitorpamplona.amethyst.service.playback.composable
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
@@ -31,18 +30,36 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.compose.ContentFrame
|
import androidx.media3.ui.compose.ContentFrame
|
||||||
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
|
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.RenderAnimatedBottomInfo
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
|
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.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.mediaitem.LoadedMediaItem
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
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
|
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? {
|
private fun getVideoSizeDp(player: Player): Size? {
|
||||||
var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat())
|
var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat())
|
||||||
|
|
||||||
@@ -71,21 +88,49 @@ fun RenderVideoPlayer(
|
|||||||
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
|
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
Box(modifier = borderModifier) {
|
val containerSize = remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val isLive = isLiveStreaming(mediaItem.src.videoUri)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
borderModifier
|
||||||
|
.onSizeChanged { containerSize.value = it }
|
||||||
|
.pointerInput(isLive, controllerState) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { controllerVisible.value = !controllerVisible.value },
|
||||||
|
onDoubleTap = { offset ->
|
||||||
|
if (!isLive) {
|
||||||
|
val isLeftSide = offset.x < containerSize.value.width / 2
|
||||||
|
if (isLeftSide) {
|
||||||
|
controllerState.controller.seekBackward()
|
||||||
|
} else {
|
||||||
|
controllerState.controller.skipForward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
ContentFrame(
|
ContentFrame(
|
||||||
player = controllerState.controller,
|
player = controllerState.controller,
|
||||||
modifier =
|
modifier = videoModifier,
|
||||||
videoModifier.clickable(
|
surfaceType = SURFACE_TYPE_TEXTURE_VIEW,
|
||||||
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.
|
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
|
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
|
||||||
|
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
|
TopGradientOverlay(
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
|
||||||
|
BottomGradientOverlay(
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
|
||||||
RenderTopButtons(
|
RenderTopButtons(
|
||||||
mediaData = mediaItem.src,
|
mediaData = mediaItem.src,
|
||||||
controllerState = controllerState,
|
controllerState = controllerState,
|
||||||
@@ -95,7 +140,12 @@ fun RenderVideoPlayer(
|
|||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
RenderCenterButtons(controllerState, controllerVisible, Modifier.align(Alignment.Center))
|
RenderCenterButtons(
|
||||||
|
controllerState = controllerState,
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
isLiveStream = isLive,
|
||||||
|
)
|
||||||
|
|
||||||
RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter))
|
RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter))
|
||||||
}
|
}
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
private val FadeIn = fadeIn()
|
||||||
|
private val FadeOut = fadeOut()
|
||||||
|
|
||||||
|
private val TopGradientColors =
|
||||||
|
listOf(
|
||||||
|
Color.Black.copy(alpha = 0.6f),
|
||||||
|
Color.Black.copy(alpha = 0.3f),
|
||||||
|
Color.Transparent,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val BottomGradientColors =
|
||||||
|
listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.4f),
|
||||||
|
Color.Black.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GradientOverlay(
|
||||||
|
controllerVisible: State<Boolean>,
|
||||||
|
colors: List<Color>,
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerVisible.value,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = FadeIn,
|
||||||
|
exit = FadeOut,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.background(brush = Brush.verticalGradient(colors = colors)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopGradientOverlay(
|
||||||
|
controllerVisible: State<Boolean>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
height: Dp = 80.dp,
|
||||||
|
) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomGradientOverlay(
|
||||||
|
controllerVisible: State<Boolean>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
height: Dp = 120.dp,
|
||||||
|
) = GradientOverlay(controllerVisible, BottomGradientColors, height, modifier)
|
||||||
+4
-4
@@ -98,10 +98,10 @@ private fun HorizontalLinearProgressIndicator(
|
|||||||
onLayoutWidthChanged: (Int) -> Unit = {},
|
onLayoutWidthChanged: (Int) -> Unit = {},
|
||||||
onSeek: (Float) -> Unit,
|
onSeek: (Float) -> Unit,
|
||||||
playedColor: Color = Color.White,
|
playedColor: Color = Color.White,
|
||||||
bufferedColor: Color = Color.LightGray,
|
bufferedColor: Color = Color.White.copy(alpha = 0.5f),
|
||||||
unplayedColor: Color = Color.DarkGray,
|
unplayedColor: Color = Color.White.copy(alpha = 0.3f),
|
||||||
scrubberColor: Color = playedColor,
|
scrubberColor: Color = playedColor,
|
||||||
rectHeightDp: Dp = 3.dp,
|
rectHeightDp: Dp = 4.dp,
|
||||||
) {
|
) {
|
||||||
Canvas(
|
Canvas(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -125,7 +125,7 @@ private fun HorizontalLinearProgressIndicator(
|
|||||||
|
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = scrubberColor,
|
color = scrubberColor,
|
||||||
radius = size.height * 1.5f,
|
radius = size.height * 2f,
|
||||||
center = Offset(x = positionX, y = size.height / 2.0f),
|
center = Offset(x = positionX, y = size.height / 2.0f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PictureInPicture
|
||||||
|
import androidx.compose.material.icons.filled.SaveAlt
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||||
|
|
||||||
|
private val FadeIn = fadeIn()
|
||||||
|
private val FadeOut = fadeOut()
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun OverflowMenuButtonPreview() {
|
||||||
|
ThemeComparisonColumn {
|
||||||
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
|
OverflowMenuButton(
|
||||||
|
showShare = true,
|
||||||
|
showSave = true,
|
||||||
|
showPip = true,
|
||||||
|
onShareClick = {},
|
||||||
|
onSaveClick = {},
|
||||||
|
onPipClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedOverflowMenuButton(
|
||||||
|
controllerVisible: State<Boolean>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showShare: Boolean = true,
|
||||||
|
showSave: Boolean = true,
|
||||||
|
showPip: Boolean = false,
|
||||||
|
onShareClick: () -> Unit,
|
||||||
|
onSaveClick: () -> Unit,
|
||||||
|
onPipClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerVisible.value,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = FadeIn,
|
||||||
|
exit = FadeOut,
|
||||||
|
) {
|
||||||
|
OverflowMenuButton(
|
||||||
|
showShare = showShare,
|
||||||
|
showSave = showSave,
|
||||||
|
showPip = showPip,
|
||||||
|
onShareClick = onShareClick,
|
||||||
|
onSaveClick = onSaveClick,
|
||||||
|
onPipClick = onPipClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OverflowMenuButton(
|
||||||
|
showShare: Boolean,
|
||||||
|
showSave: Boolean,
|
||||||
|
showPip: Boolean,
|
||||||
|
onShareClick: () -> Unit,
|
||||||
|
onSaveClick: () -> Unit,
|
||||||
|
onPipClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val menuExpanded = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(modifier = VolumeBottomIconSize) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.fillMaxSize(0.7f)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = { menuExpanded.value = true },
|
||||||
|
modifier = Size50Modifier,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringRes(R.string.more_options),
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Size20Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = menuExpanded.value,
|
||||||
|
onDismissRequest = { menuExpanded.value = false },
|
||||||
|
containerColor = Color.Black.copy(alpha = 0.85f),
|
||||||
|
) {
|
||||||
|
if (showShare) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringRes(R.string.share_or_save), color = Color.White) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded.value = false
|
||||||
|
onShareClick()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Share,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSave) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringRes(R.string.save_to_gallery), color = Color.White) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded.value = false
|
||||||
|
onSaveClick()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.SaveAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPip) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded.value = false
|
||||||
|
onPipClick()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PictureInPicture,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -37,6 +37,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
@@ -110,6 +111,7 @@ fun PlaybackSpeedPopUpButton(
|
|||||||
Box {
|
Box {
|
||||||
Text(
|
Text(
|
||||||
text = "%.1fx".format(playbackSpeed),
|
text = "%.1fx".format(playbackSpeed),
|
||||||
|
color = Color.White,
|
||||||
modifier = modifier.clickable(onClick = { openDialog = true }),
|
modifier = modifier.clickable(onClick = { openDialog = true }),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -27,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -76,6 +77,7 @@ fun PositionAndDurationText(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-3
@@ -21,13 +21,19 @@
|
|||||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
|
import com.vitorpamplona.amethyst.service.playback.composable.seekBackward
|
||||||
|
import com.vitorpamplona.amethyst.service.playback.composable.skipForward
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@@ -36,16 +42,35 @@ fun RenderCenterButtons(
|
|||||||
controllerState: MediaControllerState,
|
controllerState: MediaControllerState,
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
isLiveStream: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val state = rememberPlayPauseButtonState(controllerState.controller)
|
val state = rememberPlayPauseButtonState(controllerState.controller)
|
||||||
|
|
||||||
AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) {
|
Row(
|
||||||
state.onClick()
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (!isLiveStream) {
|
||||||
|
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = false) {
|
||||||
|
controllerState.controller.seekBackward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) {
|
||||||
|
state.onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLiveStream) {
|
||||||
|
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) {
|
||||||
|
controllerState.controller.skipForward()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.showPlay) {
|
if (!state.showPlay) {
|
||||||
LaunchedEffect(state.showPlay) {
|
LaunchedEffect(state.showPlay) {
|
||||||
delay(2000)
|
delay(3000)
|
||||||
controllerVisible.value = false
|
controllerVisible.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-46
@@ -20,16 +20,19 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
@@ -52,9 +55,11 @@ fun RenderTopButtonsPreview() {
|
|||||||
mediaData = MediaItemData("http://test.mp4"),
|
mediaData = MediaItemData("http://test.mp4"),
|
||||||
controllerVisible = remember { mutableStateOf(true) },
|
controllerVisible = remember { mutableStateOf(true) },
|
||||||
startingMuteState = false,
|
startingMuteState = false,
|
||||||
onMuteClick = { },
|
isLive = false,
|
||||||
onPictureInPictureClick = { },
|
pipSupported = true,
|
||||||
onZoomClick = { },
|
onMuteClick = {},
|
||||||
|
onPictureInPictureClick = {},
|
||||||
|
onZoomClick = {},
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
accountViewModel = mockAccountViewModel(),
|
accountViewModel = mockAccountViewModel(),
|
||||||
)
|
)
|
||||||
@@ -71,20 +76,26 @@ fun RenderTopButtons(
|
|||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current.getActivity()
|
val context = LocalContext.current
|
||||||
|
val isLive = isLiveStreaming(mediaData.videoUri)
|
||||||
|
val pipSupported =
|
||||||
|
remember {
|
||||||
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
|
}
|
||||||
|
|
||||||
RenderTopButtons(
|
RenderTopButtons(
|
||||||
mediaData = mediaData,
|
mediaData = mediaData,
|
||||||
controllerVisible = controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
startingMuteState = controllerState.controller.volume < 0.001,
|
startingMuteState = controllerState.controller.volume < 0.001,
|
||||||
|
isLive = isLive,
|
||||||
|
pipSupported = pipSupported,
|
||||||
onMuteClick = { mute ->
|
onMuteClick = { mute ->
|
||||||
// makes the new setting the default for new creations.
|
|
||||||
DEFAULT_MUTED_SETTING.value = mute
|
DEFAULT_MUTED_SETTING.value = mute
|
||||||
controllerState.controller.volume = if (mute) 0f else 1f
|
controllerState.controller.volume = if (mute) 0f else 1f
|
||||||
},
|
},
|
||||||
onPictureInPictureClick = {
|
onPictureInPictureClick = {
|
||||||
controllerState.controller.pause()
|
controllerState.controller.pause()
|
||||||
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
|
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity())
|
||||||
},
|
},
|
||||||
onZoomClick =
|
onZoomClick =
|
||||||
onZoomClick?.let {
|
onZoomClick?.let {
|
||||||
@@ -98,54 +109,31 @@ fun RenderTopButtons(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderTopButtons(
|
fun RenderTopButtons(
|
||||||
mediaData: MediaItemData,
|
mediaData: MediaItemData,
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
startingMuteState: Boolean,
|
startingMuteState: Boolean,
|
||||||
|
isLive: Boolean,
|
||||||
|
pipSupported: Boolean,
|
||||||
onMuteClick: (Boolean) -> Unit,
|
onMuteClick: (Boolean) -> Unit,
|
||||||
onPictureInPictureClick: () -> Unit,
|
onPictureInPictureClick: () -> Unit,
|
||||||
onZoomClick: (() -> Unit)?,
|
onZoomClick: (() -> Unit)?,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
Row(modifier) {
|
val shareDialogVisible = remember { mutableStateOf(false) }
|
||||||
if (!isLiveStreaming(mediaData.videoUri)) {
|
val saveAction =
|
||||||
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
|
rememberSaveMediaAction { context ->
|
||||||
ShareMediaAction(
|
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(controllerVisible.value) {
|
||||||
|
if (!controllerVisible.value) shareDialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier) {
|
||||||
if (onZoomClick != null) {
|
if (onZoomClick != null) {
|
||||||
FullScreenButton(
|
FullScreenButton(
|
||||||
controllerVisible = controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
@@ -153,15 +141,45 @@ fun RenderTopButtons(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
PictureInPictureButton(
|
|
||||||
controllerVisible = controllerVisible,
|
|
||||||
onClick = onPictureInPictureClick,
|
|
||||||
)
|
|
||||||
|
|
||||||
MuteButton(
|
MuteButton(
|
||||||
controllerVisible = controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
startingMuteState = startingMuteState,
|
startingMuteState = startingMuteState,
|
||||||
toggle = onMuteClick,
|
toggle = onMuteClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Box {
|
||||||
|
AnimatedOverflowMenuButton(
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
showShare = true,
|
||||||
|
showSave = !isLive,
|
||||||
|
showPip = pipSupported,
|
||||||
|
onShareClick = { shareDialogVisible.value = true },
|
||||||
|
onSaveClick = saveAction,
|
||||||
|
onPipClick = onPictureInPictureClick,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (shareDialogVisible.value) {
|
||||||
|
ShareMediaAction(
|
||||||
|
popupExpanded = shareDialogVisible,
|
||||||
|
videoUri = mediaData.videoUri,
|
||||||
|
postNostrUri = mediaData.callbackUri,
|
||||||
|
blurhash = null,
|
||||||
|
dim = null,
|
||||||
|
hash = null,
|
||||||
|
mimeType = mediaData.mimeType,
|
||||||
|
onDismiss = { shareDialogVisible.value = false },
|
||||||
|
content =
|
||||||
|
MediaUrlVideo(
|
||||||
|
url = mediaData.videoUri,
|
||||||
|
mimeType = mediaData.mimeType,
|
||||||
|
artworkUri = mediaData.artworkUri,
|
||||||
|
authorName = mediaData.authorName,
|
||||||
|
description = mediaData.title,
|
||||||
|
uri = mediaData.callbackUri,
|
||||||
|
),
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-28
@@ -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)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
|
fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
|
||||||
|
val saveAction = rememberSaveMediaAction(onSaveClick)
|
||||||
Box(modifier = PinBottomIconSize) {
|
Box(modifier = PinBottomIconSize) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -98,35 +125,8 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
|
|||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.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(
|
IconButton(
|
||||||
onClick = {
|
onClick = saveAction,
|
||||||
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()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Size50Modifier,
|
modifier = Size50Modifier,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Forward10
|
||||||
|
import androidx.compose.material.icons.filled.Replay10
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||||
|
|
||||||
|
private val FadeIn = fadeIn()
|
||||||
|
private val FadeOut = fadeOut()
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun SkipBackButtonPreview() {
|
||||||
|
ThemeComparisonColumn {
|
||||||
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
|
SkipButton(isForward = false, onClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun SkipForwardButtonPreview() {
|
||||||
|
ThemeComparisonColumn {
|
||||||
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
|
SkipButton(isForward = true, onClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedSkipButton(
|
||||||
|
controllerVisible: State<Boolean>,
|
||||||
|
isForward: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controllerVisible.value,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = FadeIn,
|
||||||
|
exit = FadeOut,
|
||||||
|
) {
|
||||||
|
SkipButton(isForward = isForward, onClick = onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkipButton(
|
||||||
|
isForward: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val icon = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10
|
||||||
|
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
|
||||||
|
IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) {
|
||||||
|
Icon(imageVector = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1157,6 +1157,7 @@
|
|||||||
<string name="accessibility_pushpin">Připínáček</string>
|
<string name="accessibility_pushpin">Připínáček</string>
|
||||||
<string name="accessibility_scan_qr_code">Skenovat QR kód</string>
|
<string name="accessibility_scan_qr_code">Skenovat QR kód</string>
|
||||||
<string name="accessibility_navigate_to_alby">Přejít na poskytovatele peněženky třetí strany Alby</string>
|
<string name="accessibility_navigate_to_alby">Přejít na poskytovatele peněženky třetí strany Alby</string>
|
||||||
|
<string name="accessibility_select_date">Vyberte datum</string>
|
||||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">Není možné odpovědět na koncept</string>
|
<string name="it_s_not_possible_to_reply_to_a_draft_note">Není možné odpovědět na koncept</string>
|
||||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">Není možné citovat koncept</string>
|
<string name="it_s_not_possible_to_quote_to_a_draft_note">Není možné citovat koncept</string>
|
||||||
<string name="it_s_not_possible_to_react_to_a_draft_note">Není možné reagovat na koncept</string>
|
<string name="it_s_not_possible_to_react_to_a_draft_note">Není možné reagovat na koncept</string>
|
||||||
|
|||||||
@@ -1162,6 +1162,7 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="accessibility_pushpin">Reißzwecke</string>
|
<string name="accessibility_pushpin">Reißzwecke</string>
|
||||||
<string name="accessibility_scan_qr_code">QR-Code scannen</string>
|
<string name="accessibility_scan_qr_code">QR-Code scannen</string>
|
||||||
<string name="accessibility_navigate_to_alby">Navigieren Sie zum Drittanbieter-Wallet-Anbieter Alby</string>
|
<string name="accessibility_navigate_to_alby">Navigieren Sie zum Drittanbieter-Wallet-Anbieter Alby</string>
|
||||||
|
<string name="accessibility_select_date">Datum auswählen</string>
|
||||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">Es ist nicht möglich, auf einen Entwurf zu antworten</string>
|
<string name="it_s_not_possible_to_reply_to_a_draft_note">Es ist nicht möglich, auf einen Entwurf zu antworten</string>
|
||||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">Es ist nicht möglich, einen Entwurf zu zitieren</string>
|
<string name="it_s_not_possible_to_quote_to_a_draft_note">Es ist nicht möglich, einen Entwurf zu zitieren</string>
|
||||||
<string name="it_s_not_possible_to_react_to_a_draft_note">Es ist nicht möglich, auf einen Entwurf zu reagieren</string>
|
<string name="it_s_not_possible_to_react_to_a_draft_note">Es ist nicht möglich, auf einen Entwurf zu reagieren</string>
|
||||||
|
|||||||
@@ -1157,6 +1157,7 @@
|
|||||||
<string name="accessibility_pushpin">Piones</string>
|
<string name="accessibility_pushpin">Piones</string>
|
||||||
<string name="accessibility_scan_qr_code">Escanear código QR</string>
|
<string name="accessibility_scan_qr_code">Escanear código QR</string>
|
||||||
<string name="accessibility_navigate_to_alby">Navegar para o provedor de carteira de terceiros Alby</string>
|
<string name="accessibility_navigate_to_alby">Navegar para o provedor de carteira de terceiros Alby</string>
|
||||||
|
<string name="accessibility_select_date">Selecionar data</string>
|
||||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">Não é possível responder uma nota em rascunho</string>
|
<string name="it_s_not_possible_to_reply_to_a_draft_note">Não é possível responder uma nota em rascunho</string>
|
||||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">Não é possível citar uma nota em rascunho</string>
|
<string name="it_s_not_possible_to_quote_to_a_draft_note">Não é possível citar uma nota em rascunho</string>
|
||||||
<string name="it_s_not_possible_to_react_to_a_draft_note">Não é possível reagir uma nota em rascunho</string>
|
<string name="it_s_not_possible_to_react_to_a_draft_note">Não é possível reagir uma nota em rascunho</string>
|
||||||
|
|||||||
@@ -701,6 +701,17 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
|||||||
<string name="terms_and_conditions">Določila & Pogoji</string>
|
<string name="terms_and_conditions">Določila & Pogoji</string>
|
||||||
<string name="not_available_acronym">N/A</string>
|
<string name="not_available_acronym">N/A</string>
|
||||||
<string name="relay_error_messages">Napake in obvestila s tega releja</string>
|
<string name="relay_error_messages">Napake in obvestila s tega releja</string>
|
||||||
|
<string name="relay_active_subscriptions">Aktivne naročnine</string>
|
||||||
|
<string name="relay_active_outbox">Izhodni dogodki v teku</string>
|
||||||
|
<string name="relay_req_subscriptions">REQ Naročnine (%1$d)</string>
|
||||||
|
<string name="relay_count_subscriptions">COUNT Naročnine (%1$d)</string>
|
||||||
|
<string name="relay_outbox_events">Izhodni dogodki (%1$d)</string>
|
||||||
|
<string name="relay_no_active_subscriptions">Ni aktivne naročnine za ta rele</string>
|
||||||
|
<string name="relay_filter_authors">%1$d avtorjev</string>
|
||||||
|
<string name="relay_filter_ids">%1$d ids</string>
|
||||||
|
<string name="relay_filter_since">od %1$s</string>
|
||||||
|
<string name="relay_filter_until">do %1$s</string>
|
||||||
|
<string name="relay_filter_limit">limit %1$d</string>
|
||||||
<string name="message_length">Dolžina sporočila</string>
|
<string name="message_length">Dolžina sporočila</string>
|
||||||
<string name="subscriptions">Naročnine</string>
|
<string name="subscriptions">Naročnine</string>
|
||||||
<string name="filters">Filtri</string>
|
<string name="filters">Filtri</string>
|
||||||
@@ -760,9 +771,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
|||||||
<string name="community_no_descriptor">Ta skupnost nima opisa. Obrnite se na lastnika, da ga doda</string>
|
<string name="community_no_descriptor">Ta skupnost nima opisa. Obrnite se na lastnika, da ga doda</string>
|
||||||
<string name="add_sensitive_content_label">Občutljiva vsebina</string>
|
<string name="add_sensitive_content_label">Občutljiva vsebina</string>
|
||||||
<string name="add_sensitive_content_description">Doda opozorilo o občutljivi vsebini pred prikazom te vsebine</string>
|
<string name="add_sensitive_content_description">Doda opozorilo o občutljivi vsebini pred prikazom te vsebine</string>
|
||||||
|
<string name="ui_preferences">UI Možnosti</string>
|
||||||
<string name="preferences">Nastavitve aplikacije</string>
|
<string name="preferences">Nastavitve aplikacije</string>
|
||||||
<string name="user_preferences">Nastavitve uporabnika</string>
|
<string name="user_preferences">Nastavitve uporabnika</string>
|
||||||
|
<string name="translations">Prevodi</string>
|
||||||
|
<string name="reactions">Reakcije</string>
|
||||||
<string name="settings">Nastavitve</string>
|
<string name="settings">Nastavitve</string>
|
||||||
|
<string name="account_settings">Nastavitve računa</string>
|
||||||
|
<string name="app_settings">Nastavitve aplikacije</string>
|
||||||
<string name="connectivity_type_always">Vedno</string>
|
<string name="connectivity_type_always">Vedno</string>
|
||||||
<string name="connectivity_type_wifi_only">Samo Wifi</string>
|
<string name="connectivity_type_wifi_only">Samo Wifi</string>
|
||||||
<string name="connectivity_type_unmetered_wifi_only">Samo z WiFi</string>
|
<string name="connectivity_type_unmetered_wifi_only">Samo z WiFi</string>
|
||||||
@@ -1031,13 +1047,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
|||||||
<string name="new_community_note">Novo zapisek skupnosti</string>
|
<string name="new_community_note">Novo zapisek skupnosti</string>
|
||||||
<string name="new_product">Nov produkt</string>
|
<string name="new_product">Nov produkt</string>
|
||||||
<string name="new_exclusive_geo_note">Nova geo-ekskluzivna objava</string>
|
<string name="new_exclusive_geo_note">Nova geo-ekskluzivna objava</string>
|
||||||
<string name="open_all_reactions_to_this_post">Odpri vse odzive na to objavo</string>
|
<string name="open_all_reactions_to_this_post">Odpri vse reakcije na to objavo</string>
|
||||||
<string name="close_all_reactions_to_this_post">Zapri vse odzive na to objavo</string>
|
<string name="close_all_reactions_to_this_post">Zapri vse reakcije na to objavo</string>
|
||||||
<string name="reply_description">Odgovori</string>
|
<string name="reply_description">Odgovori</string>
|
||||||
<string name="boost_or_quote_description">Posreduj ali citiraj</string>
|
<string name="boost_or_quote_description">Posreduj ali citiraj</string>
|
||||||
<string name="like_description">Všečkaj</string>
|
<string name="like_description">Všečkaj</string>
|
||||||
<string name="zap_description">Zap</string>
|
<string name="zap_description">Zap</string>
|
||||||
<string name="change_reaction">Spremeni hitre odzivne ikone</string>
|
<string name="change_reaction">Spremeni hitre reakcije</string>
|
||||||
<string name="profile_image_of_user">Slika profila %1$s</string>
|
<string name="profile_image_of_user">Slika profila %1$s</string>
|
||||||
<string name="relay_info">Rele %1$s</string>
|
<string name="relay_info">Rele %1$s</string>
|
||||||
<string name="expand_relay_list">Razširi seznam relejev</string>
|
<string name="expand_relay_list">Razširi seznam relejev</string>
|
||||||
@@ -1110,6 +1126,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
|||||||
<string name="trusted_relays_title">Zaupanja vredni releji</string>
|
<string name="trusted_relays_title">Zaupanja vredni releji</string>
|
||||||
<string name="trusted_section">Zaupanja vredni releji</string>
|
<string name="trusted_section">Zaupanja vredni releji</string>
|
||||||
<string name="trusted_section_explainer">Releji katerim zaupaš in zanje ne rabiš Tor povezave</string>
|
<string name="trusted_section_explainer">Releji katerim zaupaš in zanje ne rabiš Tor povezave</string>
|
||||||
|
<string name="favorite_relays_title">Najljubše vsebine releja</string>
|
||||||
|
<string name="favorite_section">Najljubše vsebine releja</string>
|
||||||
|
<string name="favorite_section_explainer">Releji katerih pogosto spremljaš globalne vsebine</string>
|
||||||
<string name="proxy_relays_title">Proxy releji</string>
|
<string name="proxy_relays_title">Proxy releji</string>
|
||||||
<string name="proxy_section">Proxy releji</string>
|
<string name="proxy_section">Proxy releji</string>
|
||||||
<string name="proxy_section_explainer">Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama.</string>
|
<string name="proxy_section_explainer">Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama.</string>
|
||||||
@@ -1155,6 +1174,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
|||||||
<string name="accessibility_pushpin">Pushpin</string>
|
<string name="accessibility_pushpin">Pushpin</string>
|
||||||
<string name="accessibility_scan_qr_code">Skeniraj QR kodo</string>
|
<string name="accessibility_scan_qr_code">Skeniraj QR kodo</string>
|
||||||
<string name="accessibility_navigate_to_alby">Pojdite na stran tretjega ponudnika denarnice Alby</string>
|
<string name="accessibility_navigate_to_alby">Pojdite na stran tretjega ponudnika denarnice Alby</string>
|
||||||
|
<string name="accessibility_select_date">Izberi datum</string>
|
||||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">Ni mogoče odgovoriti na osnutek zapiska</string>
|
<string name="it_s_not_possible_to_reply_to_a_draft_note">Ni mogoče odgovoriti na osnutek zapiska</string>
|
||||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">Ni mogoče citirati osnutka zapiska</string>
|
<string name="it_s_not_possible_to_quote_to_a_draft_note">Ni mogoče citirati osnutka zapiska</string>
|
||||||
<string name="it_s_not_possible_to_react_to_a_draft_note">Ni mogoče reagirati na osnutek zapiska</string>
|
<string name="it_s_not_possible_to_react_to_a_draft_note">Ni mogoče reagirati na osnutek zapiska</string>
|
||||||
@@ -1291,4 +1311,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
|||||||
<string name="confirm">Potrdi</string>
|
<string name="confirm">Potrdi</string>
|
||||||
<string name="next">Naprej</string>
|
<string name="next">Naprej</string>
|
||||||
<string name="add_poll_option_button">Dodaj gumb za glasovanje</string>
|
<string name="add_poll_option_button">Dodaj gumb za glasovanje</string>
|
||||||
|
<string name="go_back">Nazaj</string>
|
||||||
|
<string name="approve">Dovoli</string>
|
||||||
|
<string name="connected">Povezano</string>
|
||||||
|
<string name="social_proof">Družbeno dokazilo</string>
|
||||||
|
<string name="poll_submit">Oddaj</string>
|
||||||
|
<string name="restart">Ponovno zaženi</string>
|
||||||
|
<string name="chess_accept">Sprejmi</string>
|
||||||
|
<string name="chess_decline">Zavrni</string>
|
||||||
|
<string name="chess_new_game">Nova igra</string>
|
||||||
|
<string name="new_chess_game">Nova partija šaha</string>
|
||||||
|
<string name="chess_loading_game">Nalagam partijo\u2026</string>
|
||||||
|
<string name="chess_game_not_found">Partija ni najdena</string>
|
||||||
|
<string name="chess_game_waiting">Ta partija je zaključena ali pa, čaka na nasprotnika </string>
|
||||||
|
<string name="chess_game_id">ID Partije: %1$s\u2026</string>
|
||||||
|
<string name="badge_and_n_others"> in %1$d ostalih</string>
|
||||||
|
<string name="remove_language">Odstrani %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1156,6 +1156,7 @@
|
|||||||
<string name="accessibility_pushpin">Häftstift</string>
|
<string name="accessibility_pushpin">Häftstift</string>
|
||||||
<string name="accessibility_scan_qr_code">Skanna QR-kod</string>
|
<string name="accessibility_scan_qr_code">Skanna QR-kod</string>
|
||||||
<string name="accessibility_navigate_to_alby">Navigera till tredjeparts plånboksleverantören Alby</string>
|
<string name="accessibility_navigate_to_alby">Navigera till tredjeparts plånboksleverantören Alby</string>
|
||||||
|
<string name="accessibility_select_date">Välj datum</string>
|
||||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">Det går inte att svara på ett utkast</string>
|
<string name="it_s_not_possible_to_reply_to_a_draft_note">Det går inte att svara på ett utkast</string>
|
||||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">Det går inte att citera ett utkast</string>
|
<string name="it_s_not_possible_to_quote_to_a_draft_note">Det går inte att citera ett utkast</string>
|
||||||
<string name="it_s_not_possible_to_react_to_a_draft_note">Det går inte att reagera på ett utkast</string>
|
<string name="it_s_not_possible_to_react_to_a_draft_note">Det går inte att reagera på ett utkast</string>
|
||||||
|
|||||||
@@ -917,6 +917,9 @@
|
|||||||
|
|
||||||
<string name="muted_button">Muted. Click to unmute</string>
|
<string name="muted_button">Muted. Click to unmute</string>
|
||||||
<string name="mute_button">Sound on. Click to mute</string>
|
<string name="mute_button">Sound on. Click to mute</string>
|
||||||
|
<string name="skip_back">Skip back %d seconds</string>
|
||||||
|
<string name="skip_forward">Skip forward %d seconds</string>
|
||||||
|
<string name="picture_in_picture">Picture-in-Picture</string>
|
||||||
<string name="search_button">Search local and remote records</string>
|
<string name="search_button">Search local and remote records</string>
|
||||||
|
|
||||||
<string name="nip05_verified">Nostr address was verified</string>
|
<string name="nip05_verified">Nostr address was verified</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user