- Turn video controller creation into a flow, removing most of the little hacks to get the controller to work in the lifecycle

- Uses new Media3 content view frames
- Redesigns video playback interface to remove unused buttons and modernize it.
- Adds our own buttons and indicators for the video playback
- Checks if PiP is supported and active before showing the button.
- Improves click interactions with the image dialog
- Fixes Surface release bugs
- Creates new interface for the Picture in Picture view
- Fixes muting and play buttons on the Picture in Picture
This commit is contained in:
Vitor Pamplona
2026-02-24 17:28:41 -05:00
parent 3942a38f0e
commit f2410a6921
37 changed files with 1782 additions and 900 deletions

View File

@@ -76,7 +76,6 @@
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:taskAffinity=".service.playback.pip.PipVideoActivity"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">

View File

@@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.ui.platform.LocalView
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@@ -33,16 +32,16 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@Composable
fun ControlWhenPlayerIsActive(
mediaControllerState: MediaControllerState,
automaticallyStartPlayback: State<Boolean>,
automaticallyStartPlayback: Boolean,
isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
) {
val controller = mediaControllerState.controller ?: return
val controller = mediaControllerState.controller
LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
// active means being fully visible
if (isClosestToTheCenterOfTheScreen.value) {
// should auto start video from settings?
if (!automaticallyStartPlayback.value) {
if (!automaticallyStartPlayback) {
if (controller.isPlaying) {
// if it is visible, it's playing but it wasn't supposed to start automatically.
controller.pause()

View File

@@ -21,225 +21,55 @@
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.Player
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.flow.onEach
@Composable
fun GetVideoController(
mediaItem: LoadedMediaItem,
muted: Boolean = false,
play: Boolean = false,
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
) {
val context = LocalContext.current
val controllerState by remember(mediaItem) {
PlaybackServiceClient
.controllerAsFlow(
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
).onEach { state ->
Log.d("PlaybackService", "Controller instance: ${state.controller}")
val onlyOnePreparing = remember { AtomicBoolean() }
val controllerId = remember(mediaItem.src.videoUri) { MediaControllerState() }
controllerId.composed = true
val scope = rememberCoroutineScope()
// Prepares a VideoPlayer from the foreground service.
//
// TODO: Review this code because a new Disposable Effect can run
// before the onDispose of the previous composable and the onDispose
// sometimes affects the new variables, not the old ones.
DisposableEffect(key1 = mediaItem.src.videoUri) {
// If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player.
if (controllerId.needsController()) {
// If there is a connection, don't wait.
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
onlyOnePreparing.getAndSet(false)
PlaybackServiceClient.removeController(controllerId)
return@launch
}
// REQUIRED TO BE RUN IN THE MAIN THREAD
if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
controllerId.controller?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controllerId.controller?.volume = if (muted) 0f else 1f
}
}
controllerId.controller?.setMediaItem(mediaItem.item)
controllerId.controller?.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
controllerId.readyToDisplay.value = true
onlyOnePreparing.getAndSet(false)
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
}
}
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
state.controller.volume = 0f
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying")
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
state.controller.volume = if (muted) 0f else 1f
Log.d("PlaybackService", "OnEach $muted")
}
}
} else {
// has been loaded. prepare to play. This happens when the background video switches screens.
controllerId.controller?.let {
scope.launch {
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
Log.d("PlaybackService", "Preparing Existing Video ${mediaItem.src.videoUri} ")
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (muted) 0f else 1f
}
if (mediaItem.item != it.currentMediaItem) {
it.setMediaItem(mediaItem.item)
}
it.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
}
if (play) {
state.controller.playWhenReady = true
}
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
}
}
}.collectAsStateWithLifecycle(null)
onDispose {
controllerId.composed = false
if (!controllerId.pictureInPictureActive.value) {
PlaybackServiceClient.removeController(controllerId)
}
}
}
// User pauses and resumes the app. What to do with videos?
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(key1 = lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
controllerId.composed = true
// if the controller is null, restarts the controller with a new one
// if the controller is not null, just continue playing what the controller was playing
if (controllerId.needsController()) {
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
onlyOnePreparing.getAndSet(false)
PlaybackServiceClient.removeController(controllerId)
return@launch
}
// REQUIRED TO BE RUN IN THE MAIN THREAD
// checks again to make sure no other thread has created a controller.
if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
controllerId.controller?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controllerId.controller?.volume = if (muted) 0f else 1f
}
}
controllerId.controller?.setMediaItem(mediaItem.item)
controllerId.controller?.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
onlyOnePreparing.getAndSet(false)
PlaybackServiceClient.removeController(controllerId)
return@launch
}
controllerId.readyToDisplay.value = true
onlyOnePreparing.getAndSet(false)
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
}
}
}
}
}
}
if (event == Lifecycle.Event.ON_PAUSE) {
controllerId.composed = false
PlaybackServiceClient.removeController(controllerId)
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
if (controllerId.readyToDisplay.value && controllerId.active.value) {
controllerId.controller?.let {
inner(controllerId)
}
controllerState?.let {
inner(it)
}
}

View File

@@ -42,7 +42,7 @@ fun LoadThumbAndThenVideoView(
contentScale: ContentScale,
nostrUriCallback: String? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null,
onDialog: (() -> Unit)? = null,
) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
val context = LocalContext.current

View File

@@ -21,10 +21,8 @@
package com.vitorpamplona.amethyst.service.playback.composable
import android.graphics.Rect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.media3.session.MediaController
import androidx.media3.common.Player
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -34,25 +32,25 @@ class MediaControllerState(
// each composable has an ID.
val id: String = Uuid.random().toString(),
// This is filled after the controller returns from this class
var controller: MediaController? = null,
// this set's the stage to keep playing on the background or not when the user leaves the screen
val pictureInPictureActive: MutableState<Boolean> = mutableStateOf(false),
// this will be false if the screen leaves before the controller connection comes back from the service.
val active: MutableState<Boolean> = mutableStateOf(true),
// this will be set to own when the controller is ready
val readyToDisplay: MutableState<Boolean> = mutableStateOf(false),
// isCurrentlyBeingRendered
var composed: Boolean = false,
var controller: Player,
// visibility onscreen
val visibility: VisibilityData = VisibilityData(),
) {
fun isPlaying() = controller?.isPlaying == true
fun isPlaying() = controller.isPlaying
fun currrentMedia() = controller?.currentMediaItem?.mediaId
fun currrentMedia() = controller.currentMediaItem?.mediaId
fun isActive() = active.value == true
fun toggleMute() {
controller.volume = if (controller.volume == 0f) 1f else 0f
}
fun needsController() = controller == null
fun togglePlayPause() {
if (controller.isPlaying) {
controller.pause()
} else {
controller.play()
}
}
}
@Stable

View File

@@ -20,28 +20,43 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
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.graphics.toArgb
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
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.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
private fun getVideoSizeDp(player: Player): Size? {
var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat())
if (videoSize.width == 0f || videoSize.height == 0f) return null
val par = player.videoSize.pixelWidthHeightRatio
if (par < 1.0) {
videoSize = videoSize.copy(width = videoSize.width * par)
} else if (par > 1.0) {
videoSize = videoSize.copy(height = videoSize.height / par)
}
return videoSize
}
@Composable
@OptIn(UnstableApi::class)
fun RenderVideoPlayer(
@@ -52,74 +67,37 @@ fun RenderVideoPlayer(
contentScale: ContentScale,
borderModifier: Modifier,
videoModifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)?,
onDialog: (() -> Unit)? = null,
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
accountViewModel: AccountViewModel,
) {
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
Box(modifier = borderModifier) {
AndroidView(
modifier = videoModifier,
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we alrady know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
)
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = showControls
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
when (contentScale) {
ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL
ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
if (showControls) {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controllerState.controller?.pause()
innerOnDialog(it)
}
}
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
},
)
}
}
},
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.
contentScale = contentScale,
)
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
if (showControls) {
RenderControlButtons(
mediaItem.src,
controllerState,
controllerVisible,
Modifier.align(Alignment.TopEnd),
accountViewModel,
RenderTopButtons(
mediaData = mediaItem.src,
controllerState = controllerState,
controllerVisible = controllerVisible,
onZoomClick = onDialog,
modifier = Modifier.align(Alignment.TopEnd),
accountViewModel = accountViewModel,
)
} else {
controllerState.controller?.volume = 0f
RenderCenterButtons(controllerState, controllerVisible, Modifier.align(Alignment.Center))
RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter))
}
}
}

View File

@@ -65,8 +65,7 @@ fun VideoView(
dimensions: DimensionTag? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
) {
@@ -79,7 +78,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel)
}
@Composable
@@ -96,11 +95,10 @@ fun VideoView(
dimensions: DimensionTag? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
onDialog: (() -> Unit)? = null,
alwaysShowVideo: Boolean = false,
showControls: Boolean = true,
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback =
remember {
@@ -137,9 +135,8 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
automaticallyStartPlayback = automaticallyStartPlayback.value,
onZoom = onDialog,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -184,9 +181,8 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
automaticallyStartPlayback = automaticallyStartPlayback.value,
onZoom = onDialog,
accountViewModel = accountViewModel,
showControls = showControls,
)

View File

@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -46,9 +46,9 @@ fun VideoViewInner(
artworkUri: String? = null,
authorName: String? = null,
nostrUriCallback: String? = null,
automaticallyStartPlayback: State<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null,
automaticallyStartPlayback: Boolean,
controllerVisible: MutableState<Boolean> = mutableStateOf(true),
onZoom: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -80,8 +80,8 @@ fun VideoViewInner(
contentScale = contentScale,
borderModifier = borderModifier,
videoModifier = videoModifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
controllerVisible = controllerVisible,
onDialog = onZoom,
accountViewModel = accountViewModel,
)
}

View File

@@ -0,0 +1,97 @@
/*
* 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.ZoomOutMap
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.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.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun FullScreenButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
FullScreenButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) {}
}
}
}
@Composable
fun FullScreenButton(
controllerVisible: MutableState<Boolean>,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
Box(modifier = PinBottomIconSize) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
IconButton(
onClick = onClick,
modifier = Size50Modifier,
) {
Icon(
imageVector = Icons.Default.ZoomOutMap,
contentDescription = stringRes(id = R.string.enter_picture_in_picture),
modifier = modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
}
}

View File

@@ -0,0 +1,132 @@
/*
* 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.annotation.OptIn
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberProgressStateWithTickCount
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun HorizontalLinearProgressIndicatorPreview() {
ThemeComparisonColumn {
Column(Modifier.background(BitcoinOrange)) {
HorizontalLinearProgressIndicator(
currentPositionProgress = { 0.00f },
bufferedPositionProgress = { 0.10f },
onLayoutWidthChanged = { widthPx -> },
onSeek = {},
)
HorizontalLinearProgressIndicator(
currentPositionProgress = { 0.10f },
bufferedPositionProgress = { 0.20f },
onLayoutWidthChanged = { widthPx -> },
onSeek = {},
)
HorizontalLinearProgressIndicator(
currentPositionProgress = { 1.00f },
bufferedPositionProgress = { 1.00f },
onLayoutWidthChanged = { widthPx -> },
onSeek = {},
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun HorizontalLinearProgressIndicator(
player: Player,
totalTickCount: Int = 0,
) {
var ticks by remember(totalTickCount) { mutableIntStateOf(totalTickCount) }
val progressState = rememberProgressStateWithTickCount(player, ticks)
HorizontalLinearProgressIndicator(
currentPositionProgress = { progressState.currentPositionProgress },
bufferedPositionProgress = { progressState.bufferedPositionProgress },
onLayoutWidthChanged = { widthPx -> if (totalTickCount == 0) ticks = widthPx },
onSeek = { pos -> player.seekTo((pos * player.duration).toLong()) },
)
}
@Composable
private fun HorizontalLinearProgressIndicator(
currentPositionProgress: () -> Float,
bufferedPositionProgress: () -> Float = currentPositionProgress,
onLayoutWidthChanged: (Int) -> Unit = {},
onSeek: (Float) -> Unit,
playedColor: Color = Color.White,
bufferedColor: Color = Color.LightGray,
unplayedColor: Color = Color.DarkGray,
scrubberColor: Color = playedColor,
rectHeightDp: Dp = 3.dp,
) {
Canvas(
Modifier
.fillMaxWidth()
.padding(horizontal = rectHeightDp * 2.5f)
.pointerInput(Unit) {
detectTapGestures { offset ->
// Capture the exact position
onSeek(offset.x / this.size.width.toFloat())
}
}.padding(vertical = rectHeightDp * 2)
.height(rectHeightDp)
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) },
) {
val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f)
val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f)
drawRect(unplayedColor, size = Size(size.width, size.height))
drawRect(bufferedColor, size = Size(bufferX, size.height))
drawRect(playedColor, size = Size(positionX, size.height))
drawCircle(
color = scrubberColor,
radius = size.height * 1.5f,
center = Offset(x = positionX, y = size.height / 2.0f),
)
}
}

View File

@@ -41,20 +41,38 @@ 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.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.playback.composable.controls.HorizontalLinearProgressIndicator
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Preview
@Composable
fun MuteButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
MuteButton(
controllerVisible = remember { mutableStateOf(true) },
startingMuteState = true,
modifier = Modifier,
) {}
}
}
}
@Composable
fun MuteButton(
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
modifier: Modifier,
modifier: Modifier = Modifier,
toggle: (Boolean) -> Unit,
) {
val holdOn =
@@ -83,7 +101,7 @@ fun MuteButton(
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)

View File

@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import android.app.AppOpsManager
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -31,23 +33,54 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.ui.note.EnablePiP
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun PictureInPictureButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
PictureInPictureButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) {}
}
}
}
@Composable
fun PictureInPictureButton(
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val context = LocalContext.current
val isAllowed =
remember {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode =
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
android.os.Process.myUid(),
context.packageName,
)
mode == AppOpsManager.MODE_ALLOWED
}
AnimatedVisibility(
visible = controllerVisible.value,
visible = isAllowed && controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
@@ -56,7 +89,7 @@ fun PictureInPictureButton(
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)

View File

@@ -0,0 +1,130 @@
/*
* 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.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.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.PlayIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun AnimatedPlayButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedPlayPauseButton(
controllerVisible = remember { mutableStateOf(true) },
isPlaying = true,
modifier = Modifier,
) { }
}
}
}
@Preview
@Composable
fun AnimatedPauseButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedPlayPauseButton(
controllerVisible = remember { mutableStateOf(true) },
isPlaying = false,
modifier = Modifier,
) { }
}
}
}
@Composable
fun AnimatedPlayPauseButton(
controllerVisible: State<Boolean>,
modifier: Modifier = Modifier,
isPlaying: Boolean,
onClick: () -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
PlayPauseButton(isPlaying, onClick)
}
}
@Composable
fun PlayPauseButton(
isPlaying: Boolean,
onClick: () -> Unit,
) {
Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.background(MaterialTheme.colorScheme.background),
)
IconButton(
onClick = onClick,
modifier = Modifier.size(80.dp),
) {
if (!isPlaying) {
Icon(
imageVector = Icons.Default.PlayArrow,
modifier = Size50Modifier,
contentDescription = stringRes(R.string.play),
)
} else {
Icon(
imageVector = Icons.Default.Pause,
modifier = Size50Modifier,
contentDescription = stringRes(R.string.play),
)
}
}
}
}

View File

@@ -0,0 +1,167 @@
/*
* 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.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlaybackSpeedState
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Preview
@Composable
fun PlaybackSpeedPopUpButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
PlaybackSpeedPopUpButton(
playbackSpeed = 0.50f,
updatePlaybackSpeed = {},
modifier = Modifier,
)
}
}
}
@Preview
@Composable
fun BottomDialogOfChoicesBodyPreview() {
ThemeComparisonRow {
Box(Modifier.background(BitcoinOrange)) {
BottomDialogOfChoicesBody(
currentSpeed = 0.50f,
choices = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
onDismissRequest = {},
onSelectChoice = {},
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun PlaybackSpeedPopUpButton(
player: Player,
modifier: Modifier = Modifier,
speedSelection: ImmutableList<Float> = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
) {
val state = rememberPlaybackSpeedState(player)
if (state.isEnabled) {
PlaybackSpeedPopUpButton(
state.playbackSpeed,
state::updatePlaybackSpeed,
modifier,
speedSelection,
)
}
}
@OptIn(UnstableApi::class)
@Composable
fun PlaybackSpeedPopUpButton(
playbackSpeed: Float,
updatePlaybackSpeed: (speed: Float) -> Unit,
modifier: Modifier = Modifier,
speedSelection: ImmutableList<Float> = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
) {
var openDialog by remember { mutableStateOf(false) }
Box {
Text(
text = "%.1fx".format(playbackSpeed),
modifier = modifier.clickable(onClick = { openDialog = true }),
style = MaterialTheme.typography.labelLarge,
)
if (openDialog) {
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = { openDialog = false },
properties = PopupProperties(focusable = true),
) {
BottomDialogOfChoicesBody(
currentSpeed = playbackSpeed,
choices = speedSelection,
onDismissRequest = { openDialog = false },
onSelectChoice = updatePlaybackSpeed,
)
}
}
}
}
@Composable
private fun BottomDialogOfChoicesBody(
currentSpeed: Float,
choices: ImmutableList<Float>,
onDismissRequest: () -> Unit,
onSelectChoice: (Float) -> Unit,
) {
val colors =
ButtonDefaults.textButtonColors().copy(
contentColor = MaterialTheme.colorScheme.onBackground,
)
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
choices.forEach { speed ->
TextButton(
colors = colors,
onClick = {
onSelectChoice(speed)
onDismissRequest()
},
) {
var fontWeight = FontWeight(400)
if (speed == currentSpeed) {
fontWeight = FontWeight(1000)
}
Text("%.1fx".format(speed), fontWeight = fontWeight)
}
}
}
}

View File

@@ -0,0 +1,82 @@
/*
* 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.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@OptIn(UnstableApi::class)
@Preview
@Composable
fun PositionAndDurationTextPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
PositionAndDurationText(
133000,
1000000,
modifier = Modifier,
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun PositionAndDurationText(
player: Player,
modifier: Modifier = Modifier,
) {
val state = rememberProgressStateWithTickInterval(player, tickIntervalMs = 1000L)
PositionAndDurationText(
state.currentPositionMs,
state.durationMs,
modifier,
)
}
@UnstableApi
@Composable
fun PositionAndDurationText(
positionMs: Long,
durationMs: Long,
modifier: Modifier = Modifier,
) {
val text = "${Util.getStringForTime(positionMs)} / ${Util.getStringForTime(durationMs)}"
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
modifier = modifier,
)
}

View File

@@ -0,0 +1,78 @@
/*
* 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.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
@OptIn(UnstableApi::class)
@Composable
fun RenderAnimatedBottomInfo(
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
// Button panel controls
Column(modifier.fillMaxWidth()) {
HorizontalLinearProgressIndicator(controllerState.controller)
RenderBottomButtonLine(controllerState)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun RenderBottomButtonLine(controllerState: MediaControllerState) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
PositionAndDurationText(controllerState.controller)
Spacer(Modifier.weight(1f))
PlaybackSpeedPopUpButton(controllerState.controller)
}
}

View File

@@ -18,18 +18,35 @@
* 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.pip
package com.vitorpamplona.amethyst.service.playback.composable.controls
import android.app.Activity
import android.graphics.Rect
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import kotlinx.coroutines.delay
fun Activity.enterPipMode(
ratio: Float?,
bounds: Rect?,
@OptIn(UnstableApi::class)
@Composable
fun RenderCenterButtons(
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
) {
if (!isInPictureInPictureMode) {
enterPictureInPictureMode(makePipParams(ratio, bounds))
} else {
setPictureInPictureParams(makePipParams(ratio, bounds))
val state = rememberPlayPauseButtonState(controllerState.controller)
AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) {
state.onClick()
}
if (!state.showPlay) {
LaunchedEffect(state.showPlay) {
delay(2000)
controllerVisible.value = false
}
}
}

View File

@@ -1,82 +0,0 @@
/*
* 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.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
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.theme.Size110dp
import com.vitorpamplona.amethyst.ui.theme.Size165dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@Composable
fun RenderControlButtons(
mediaData: MediaItemData,
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
buttonPositionModifier: Modifier,
accountViewModel: AccountViewModel,
) {
MuteButton(
controllerVisible,
(controllerState.controller?.volume ?: 0f) < 0.001,
buttonPositionModifier,
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
controllerState.controller?.volume = if (mute) 0f else 1f
}
val context = LocalContext.current.getActivity()
PictureInPictureButton(
controllerVisible,
buttonPositionModifier.padding(end = Size55dp),
) {
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
}
if (!isLiveStreaming(mediaData.videoUri)) {
AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context ->
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
}
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle ->
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
}
} else {
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle ->
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
}
}
}

View File

@@ -0,0 +1,167 @@
/*
* 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.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
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.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
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.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun RenderTopButtonsPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
RenderTopButtons(
mediaData = MediaItemData("http://test.mp4"),
controllerVisible = remember { mutableStateOf(true) },
startingMuteState = false,
onMuteClick = { },
onPictureInPictureClick = { },
onZoomClick = { },
modifier = Modifier,
accountViewModel = mockAccountViewModel(),
)
}
}
}
@Composable
fun RenderTopButtons(
mediaData: MediaItemData,
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
onZoomClick: (() -> Unit)?,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current.getActivity()
RenderTopButtons(
mediaData = mediaData,
controllerVisible = controllerVisible,
startingMuteState = controllerState.controller.volume < 0.001,
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)
},
onZoomClick =
onZoomClick?.let {
{
controllerState.controller.pause()
it()
}
},
modifier = modifier,
accountViewModel = accountViewModel,
)
}
@Composable
fun RenderTopButtons(
mediaData: MediaItemData,
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
onMuteClick: (Boolean) -> Unit,
onPictureInPictureClick: () -> Unit,
onZoomClick: (() -> Unit)?,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
Row(modifier) {
if (!isLiveStreaming(mediaData.videoUri)) {
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
ShareMediaAction(
popupExpanded = popupExpanded,
videoUri = mediaData.videoUri,
postNostrUri = mediaData.callbackUri,
blurhash = null,
dim = null,
hash = null,
mimeType = mediaData.mimeType,
onDismiss = toggle,
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
accountViewModel = accountViewModel,
)
}
AnimatedSaveButton(controllerVisible) { context ->
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
}
} else {
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
ShareMediaAction(
popupExpanded = popupExpanded,
videoUri = mediaData.videoUri,
postNostrUri = mediaData.callbackUri,
blurhash = null,
dim = null,
hash = null,
mimeType = mediaData.mimeType,
onDismiss = toggle,
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
accountViewModel = accountViewModel,
)
}
}
if (onZoomClick != null) {
FullScreenButton(
controllerVisible = controllerVisible,
onClick = onZoomClick,
)
}
PictureInPictureButton(
controllerVisible = controllerVisible,
onClick = onPictureInPictureClick,
)
MuteButton(
controllerVisible = controllerVisible,
startingMuteState = startingMuteState,
toggle = onMuteClick,
)
}
}

View File

@@ -38,26 +38,43 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.launch
@Preview
@Composable
fun AnimatedSaveButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedSaveButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) {}
}
}
}
@Composable
fun AnimatedSaveButton(
controllerVisible: State<Boolean>,
modifier: Modifier,
modifier: Modifier = Modifier,
onSaveClick: (localContext: Context) -> Unit,
) {
AnimatedVisibility(
@@ -77,7 +94,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)

View File

@@ -40,16 +40,32 @@ 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.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.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun AnimatedShareButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedShareButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) { _, _ -> }
}
}
}
@Composable
fun AnimatedShareButton(
controllerVisible: State<Boolean>,
modifier: Modifier,
modifier: Modifier = Modifier,
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
) {
AnimatedVisibility(
@@ -64,13 +80,13 @@ fun AnimatedShareButton(
@Composable
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
Box(modifier = PinBottomIconSize) {
Box(modifier = PinBottomIconSize, contentAlignment = Alignment.Center) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
modifier =
Modifier
.clip(CircleShape)
.fillMaxSize(0.7f)
.background(MaterialTheme.colorScheme.background),
)
val popupExpanded = remember { mutableStateOf(false) }

View File

@@ -30,7 +30,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -40,7 +39,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta
import kotlin.math.abs
// This keeps the position of all visible videos in the current screen.
val trackingVideos = mutableListOf<MediaControllerState>()
val trackingVideos = mutableSetOf<MediaControllerState>()
/**
* This function selects only one Video to be active. The video that is closest to the center of the
@@ -52,17 +51,21 @@ fun VideoPlayerActiveMutex(
inner: @Composable (Modifier, MutableState<Boolean>) -> Unit,
) {
// Is the current video the closest to the center?
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(false) }
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) }
// Keep track of all available videos.
DisposableEffect(key1 = controller) {
trackingVideos.add(controller)
onDispose { trackingVideos.remove(controller) }
onDispose {
trackingVideos.remove(controller)
}
}
val view = LocalView.current
val videoModifier =
remember(controller) {
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { bounds, distanceToCenter ->
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges(view) { bounds, distanceToCenter ->
controller.visibility.bounds = bounds
controller.visibility.distanceToCenter = distanceToCenter
@@ -93,15 +96,14 @@ fun VideoPlayerActiveMutex(
inner(videoModifier, isClosestToTheCenterOfTheScreen)
}
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier =
composed {
val view = LocalView.current
onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
}
fun Modifier.onVisiblePositionChanges(
view: View,
onVisiblePosition: (Rect, Float?) -> Unit,
): Modifier =
onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
}
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(

View File

@@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
@@ -56,31 +55,25 @@ fun Waveform(
val restartFlow = remember { mutableIntStateOf(0) }
val myController = mediaControllerState.controller
// Keeps the screen on while playing and viewing videos.
if (myController != null) {
DisposableEffect(key1 = myController) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (isPlaying) {
restartFlow.intValue += 1
}
DisposableEffect(key1 = mediaControllerState.controller) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (isPlaying) {
restartFlow.intValue += 1
}
}
}
myController.addListener(listener)
onDispose { myController.removeListener(listener) }
}
mediaControllerState.controller.addListener(listener)
onDispose { mediaControllerState.controller.removeListener(listener) }
}
LaunchedEffect(key1 = restartFlow.intValue) {
mediaControllerState.controller?.let {
pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
}
pollCurrentDuration(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value }
}
LaunchedEffect(Unit) {
@@ -90,7 +83,7 @@ fun Waveform(
}
}
private fun pollCurrentDuration(controller: MediaController) =
private fun pollCurrentDuration(controller: Player) =
flow {
while (controller.currentPosition <= controller.duration) {
emit(controller.currentPosition / controller.duration.toFloat())

View File

@@ -20,36 +20,43 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import kotlinx.coroutines.flow.MutableStateFlow
object BackgroundMedia {
// background playing mutex.
val bgInstance = MutableStateFlow<MediaControllerState?>(null)
var bgInstance: MediaControllerState? = null
fun hasInstance() = bgInstance.value != null
fun hasInstance() = bgInstance != null
fun isPlaying() = bgInstance.value?.isPlaying() == true
fun isPlaying() = bgInstance?.isPlaying() == true
fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance.value?.id
fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance?.id
fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState)
fun removeBackgroundControllerAndReleaseIt() {
bgInstance.value?.let {
PlaybackServiceClient.removeController(it)
bgInstance.tryEmit(null)
}
bgInstance = null
}
fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
bgInstance.tryEmit(mediaControllerState)
bgInstance = mediaControllerState
}
fun clearBackground(mediaControllerState: MediaControllerState) {
if (bgInstance.value == mediaControllerState) {
bgInstance.tryEmit(null)
if (bgInstance == mediaControllerState) {
bgInstance = null
}
}
}
@Composable
fun RegisterBackgroundMedia(controllerState: MediaControllerState) {
DisposableEffect(controllerState) {
BackgroundMedia.switchKeepPlaying(controllerState)
onDispose {
BackgroundMedia.clearBackground(controllerState)
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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.pip
import android.app.Activity
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.ui.components.getActivity
import kotlinx.coroutines.launch
@Composable
fun rememberVideoDataFromIntents(): MutableState<MediaItemData?> {
val activity = LocalContext.current.getActivity()
val videoData =
remember {
mutableStateOf(activity.processIntentForPiP(activity.intent))
}
val scope = rememberCoroutineScope()
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
scope.launch {
videoData.value = activity.processIntentForPiP(intent)
}
}
activity.addOnNewIntentListener(consumer)
onDispose {
activity.removeOnNewIntentListener(consumer)
}
}
return videoData
}
fun Activity.processIntentForPiP(intent: Intent): MediaItemData? {
val videoDataOnCreation = IntentExtras.loadBundle(intent.extras)
val bounds = IntentExtras.loadBounds(intent.extras)
val ratio = videoDataOnCreation?.aspectRatio ?: videoDataOnCreation?.videoUri?.let { MediaAspectRatioCache.get(it) }
val isPlaying = true
val isMuted = DEFAULT_MUTED_SETTING.value
if (!isInPictureInPictureMode) {
enterPictureInPictureMode(makePipParams(isPlaying, isMuted, ratio, bounds))
} else {
setPictureInPictureParams(makePipParams(isPlaying, isMuted, ratio, bounds))
}
return videoDataOnCreation
}

View File

@@ -20,20 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.app.Activity
import android.app.PictureInPictureParams
import android.graphics.Rect
import android.os.Build
import android.util.Rational
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.util.Consumer
import com.vitorpamplona.amethyst.ui.components.getActivity
fun makePipParams(
ratio: Float?,
@@ -49,18 +40,25 @@ fun makePipParams(
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
}.build()
@Composable
fun rememberIsInPipMode(): Boolean {
val activity = LocalContext.current.getActivity()
var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
DisposableEffect(activity) {
val observer =
Consumer<PictureInPictureModeChangedInfo> { info ->
pipMode = info.isInPictureInPictureMode
fun Activity.makePipParams(
isPlaying: Boolean,
isMuted: Boolean,
ratio: Float?,
bounds: Rect?,
): PictureInPictureParams =
PictureInPictureParams
.Builder()
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
}
activity.addOnPictureInPictureModeChangedListener(observer)
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
}
return pipMode
}
setActions(
listOf(
createPlayPauseAction(this@makePipParams, isPlaying),
createMuteAction(this@makePipParams, isMuted),
),
)
bounds?.let { setSourceRectHint(bounds) }
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
}.build()

View File

@@ -23,15 +23,18 @@ package com.vitorpamplona.amethyst.service.playback.pip
import android.app.ActivityOptions
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
class PipVideoActivity : ComponentActivity() {
@@ -39,22 +42,22 @@ class PipVideoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var videoData = IntentExtras.loadBundle(this.intent.extras)
val bounds = IntentExtras.loadBounds(this.intent.extras)
val ratio = videoData?.aspectRatio ?: videoData?.videoUri?.let { MediaAspectRatioCache.get(it) }
enterPipMode(ratio, bounds)
setContent {
PipVideo()
}
}
val videoData by rememberVideoDataFromIntents()
videoData?.let { mediaItemData ->
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
GetMediaItem(mediaItemData) { mediaItem ->
GetVideoController(mediaItem, muted, true) { controllerState ->
RegisterBackgroundMedia(controllerState)
RegisterControllerReceiver(controllerState)
WatchControllerForActions(mediaItemData, controllerState)
RenderPipVideo(controllerState, mediaItemData.waveformData)
}
}
}
}
}
override fun onStop() {
@@ -67,11 +70,6 @@ class PipVideoActivity : ComponentActivity() {
super.finish()
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
finishAndRemoveTask()
}
companion object {
fun callIn(
videoData: MediaItemData,
@@ -86,13 +84,10 @@ class PipVideoActivity : ComponentActivity() {
} else {
ActivityOptions.makeBasic()
}
options.setLaunchBounds(videoBounds)
context.startActivity(
Intent(context, PipVideoActivity::class.java).apply {
Intent(context.applicationContext, PipVideoActivity::class.java).apply {
putExtras(IntentExtras.createBundle(videoData, videoBounds))
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
},
options.toBundle(),
)

View File

@@ -20,109 +20,58 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.content.Context
import android.content.Intent
import android.content.Context.RECEIVER_EXPORTED
import android.os.Build
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.util.Consumer
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.state.rememberMuteButtonState
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.pip.makePipParams
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
@Composable
fun PipVideo() {
val activity = LocalContext.current.getActivity()
var videoData by remember(activity) {
mutableStateOf(IntentExtras.loadBundle(activity.intent.extras))
}
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
videoData = IntentExtras.loadBundle(intent.extras)
val bounds = IntentExtras.loadBounds(intent.extras)
val ratio = videoData?.aspectRatio ?: videoData?.videoUri?.let { MediaAspectRatioCache.get(it) }
activity.enterPipMode(ratio, bounds)
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
videoData?.let {
GetMediaItem(it) { mediaItem ->
GetVideoController(mediaItem, false) { controller ->
PipVideo(controller, it.waveformData)
}
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun PipVideo(
fun RenderPipVideo(
controller: MediaControllerState,
waveformData: WaveformData?,
) {
DisposableEffect(controller) {
BackgroundMedia.switchKeepPlaying(controller)
onDispose {
BackgroundMedia.clearBackground(controller)
}
}
val ratio =
controller.currrentMedia()?.let {
MediaAspectRatioCache.get(it)
}
val modifier =
if (ratio != null) {
Modifier.aspectRatio(ratio)
} else {
Modifier
remember {
val ratio =
controller.currrentMedia()?.let {
MediaAspectRatioCache.get(it)
}
if (ratio != null) {
Modifier.aspectRatio(ratio)
} else {
Modifier
}
}
Box(modifier, contentAlignment = Alignment.Center) {
AndroidView(
modifier = Modifier,
factory = { context: Context ->
PlayerView(context).apply {
clipToOutline = true
player = controller.controller
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
controllerAutoShow = false
useController = false
hideController()
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
controller.controller?.playWhenReady = true
}
},
ContentFrame(
player = controller.controller,
keepContentOnReset = true,
contentScale = ContentScale.Crop,
)
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
@@ -130,3 +79,51 @@ fun PipVideo(
}
}
}
@Composable
fun RegisterControllerReceiver(controllerState: MediaControllerState) {
val context = LocalContext.current
// Use DisposableEffect to manage the receiver's lifecycle
DisposableEffect(context) {
val receiver =
ActionReceiver(
onMute = controllerState::toggleMute,
onPlayPause = controllerState::togglePlayPause,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, receiver.filterMute, RECEIVER_EXPORTED)
context.registerReceiver(receiver, receiver.filterPlayPause, RECEIVER_EXPORTED)
} else {
context.registerReceiver(receiver, receiver.filterMute)
context.registerReceiver(receiver, receiver.filterPlayPause)
}
onDispose {
context.unregisterReceiver(receiver)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun WatchControllerForActions(
mediaItemData: MediaItemData,
controllerState: MediaControllerState,
) {
val playPauseState = rememberPlayPauseButtonState(controllerState.controller)
val muteState = rememberMuteButtonState(controllerState.controller)
val activity = LocalContext.current.getActivity()
LaunchedEffect(activity, playPauseState.showPlay, muteState.showMuted) {
activity.setPictureInPictureParams(
activity.makePipParams(
isPlaying = !playPauseState.showPlay,
isMuted = !muteState.showMuted,
ratio = mediaItemData.aspectRatio,
bounds = null,
),
)
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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.pip
import android.app.PendingIntent
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
const val ACTION_MUTE = "com.vitorpamplona.amethyst.MUTE"
const val ACTION_PLAY_PAUSE = "com.vitorpamplona.amethyst.PLAY_PAUSE"
class ActionReceiver(
val onMute: () -> Unit,
val onPlayPause: () -> Unit,
) : BroadcastReceiver() {
val filterMute = IntentFilter(ACTION_MUTE)
val filterPlayPause = IntentFilter(ACTION_PLAY_PAUSE)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_MUTE -> onMute()
ACTION_PLAY_PAUSE -> onPlayPause()
}
}
}
fun createMuteAction(
context: Context,
isMuted: Boolean,
): RemoteAction {
val icon =
if (isMuted) {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_up)
} else {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_off)
}
val title = if (isMuted) stringRes(context, R.string.muted_button) else stringRes(context, R.string.mute_button)
val intent =
PendingIntent.getBroadcast(
context,
0,
Intent(ACTION_MUTE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return RemoteAction(icon, title, title, intent)
}
fun createPlayPauseAction(
context: Context,
isPlaying: Boolean,
): RemoteAction {
val icon =
if (!isPlaying) {
Icon.createWithResource(context, androidx.media3.ui.compose.material3.R.drawable.media3_icon_play)
} else {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_pause)
}
val title = if (!isPlaying) stringRes(context, R.string.play) else stringRes(context, R.string.pause)
val intent =
PendingIntent.getBroadcast(
context,
0,
Intent(ACTION_PLAY_PAUSE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return RemoteAction(icon, title, title, intent)
}

View File

@@ -45,7 +45,7 @@ class ExoPlayerPool(
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("BundledInsert", "Caught exception: ${throwable.message}", throwable)
Log.e("PlaybackService", "Caught exception: ${throwable.message}", throwable)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
@@ -75,15 +75,19 @@ class ExoPlayerPool(
suspend fun releasePlayer(player: ExoPlayer) {
mutex.withLock {
player.pause()
player.stop()
player.clearMediaItems()
if (playerPool.size < poolSize) {
if (!playerPool.contains(player)) {
playerPool.add(player)
if (!player.isReleased) {
player.pause()
player.stop()
player.clearVideoSurface()
player.clearMediaItems()
if (playerPool.size < poolSize) {
if (!playerPool.contains(player)) {
playerPool.add(player)
}
} else {
player.release() // Release if pool is full.
}
} else {
player.release() // Release if pool is full.
}
}
}

View File

@@ -36,7 +36,6 @@ import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -99,10 +98,11 @@ class MediaSessionPool(
.Builder(context, exoPlayerPool.acquirePlayer(context))
.apply {
setBitmapLoader(
DataSourceBitmapLoader(
DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(),
OkHttpDataSource.Factory(okHttpClient),
),
DataSourceBitmapLoader
.Builder(context)
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
.build(),
)
setId(id)
setCallback(globalCallback)
@@ -125,11 +125,9 @@ class MediaSessionPool(
session.player.removeListener(listener.playerListener)
}
cache.remove(session.id)
playingMap.remove(session.id)
cache.remove(session.id)
session.release()
exoPlayerPool.releasePlayerAsync(session.player as ExoPlayer)
cleanupUnused()
}
@@ -144,7 +142,6 @@ class MediaSessionPool(
// but not connected yet.
// delay(10000)
snap.values.forEach {
Log.d("MediaSessionPoll", "Clean Up Unused ${it.session.connectedControllers.size} ${it.session.id}")
if (it.session.connectedControllers.isEmpty()) {
releaseSession(it.session)
counter++

View File

@@ -117,7 +117,7 @@ class PlaybackService : MediaSessionService() {
// if nothing is pl
if (playing.isEmpty() && BackgroundMedia.hasInstance()) {
BackgroundMedia.bgInstance.value?.id?.let { id ->
BackgroundMedia.bgInstance?.id?.let { id ->
(poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let {
super.onUpdateNotification(it, startInForegroundRequired)
}
@@ -126,7 +126,7 @@ class PlaybackService : MediaSessionService() {
}
playing.forEachIndexed { idx, it ->
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance.value?.id) {
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
super.onUpdateNotification(it.session, startInForegroundRequired)
return
}

View File

@@ -27,91 +27,77 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object PlaybackServiceClient {
val executorService: ExecutorService = Executors.newCachedThreadPool()
fun removeController(mediaControllerState: MediaControllerState) {
mediaControllerState.active.value = false
mediaControllerState.readyToDisplay.value = false
val myController = mediaControllerState.controller
// release when can
if (myController != null) {
mediaControllerState.controller = null
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Main) {
// myController.pause()
// myController.stop()
myController.release()
Log.d("PlaybackService", "Releasing Video $mediaControllerState")
}
}
}
fun prepareController(
mediaControllerState: MediaControllerState,
@OptIn(ExperimentalUuidApi::class)
fun controllerAsFlow(
videoUri: String,
proxyPort: Int? = 0,
keepPlaying: Boolean = true,
context: Context,
onReady: (MediaControllerState) -> Unit,
) {
) = callbackFlow {
val appContext = context.applicationContext
val id = Uuid.random().toString()
mediaControllerState.active.value = true
try {
val bundle =
Bundle().apply {
// link the id with the client's id to make sure it can return the
// same session on background media.
putString("id", mediaControllerState.id)
putBoolean("keepPlaying", keepPlaying)
proxyPort?.let {
putInt("proxyPort", it)
}
val bundle =
Bundle().apply {
// link the id with the client's id to make sure it can return the
// same session on background media.
putString("id", id)
putBoolean("keepPlaying", keepPlaying)
proxyPort?.let {
putInt("proxyPort", it)
}
}
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
val controllerFuture =
MediaController
.Builder(appContext, session)
.setConnectionHints(bundle)
.buildAsync()
val controllerFuture =
MediaController
.Builder(appContext, session)
.setConnectionHints(bundle)
.buildAsync()
Log.d("PlaybackService", "Preparing Controller ${mediaControllerState.id} $videoUri")
Log.d("PlaybackService", "Preparing Controller $id $videoUri")
controllerFuture.addListener(
{
try {
val controller = controllerFuture.get()
mediaControllerState.controller = controller
controllerFuture.addListener(
{
try {
val controller = controllerFuture.get(5, TimeUnit.SECONDS)
// checks if the player is still active before engaging further
if (mediaControllerState.isActive()) {
onReady(mediaControllerState)
} else {
removeController(mediaControllerState)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
executorService,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
Log.d("PlaybackService", "Controller Ready $id $videoUri")
// checks if the player is still active before engaging further
trySend(
MediaControllerState(
id = id,
controller = controller,
),
)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $id $videoUri", e)
close(e)
}
},
executorService,
)
awaitClose {
Log.d("PlaybackService", "Releasing Controller $id $videoUri")
try {
MediaController.releaseFuture(controllerFuture)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to release Playback Client for $id $videoUri ${e.message}")
}
}
}
}

View File

@@ -28,6 +28,8 @@ import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
@@ -125,9 +127,7 @@ fun ZoomableImageDialog(
}
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize(), Alignment.TopCenter) {
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
}
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
}
}
}
@@ -158,136 +158,143 @@ private fun DialogContent(
}
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
) { index ->
allImages.getOrNull(index)?.let {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo(
content = it,
roundedCorner = false,
isFiniteHeight = true,
controllerVisible = controllerVisible,
onControllerVisibilityChanged = { controllerVisible.value = it },
onToggleControllerVisibility = {
controllerVisible.value = !controllerVisible.value
},
accountViewModel = accountViewModel,
)
Box(
Modifier
.fillMaxSize()
.clickable(
onClick = {
controllerVisible.value = !controllerVisible.value
},
),
Alignment.TopCenter,
) {
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
) { index ->
allImages.getOrNull(index)?.let {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo(
content = it,
roundedCorner = false,
isFiniteHeight = true,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
)
}
}
}
}
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo(
content = imageUrl,
roundedCorner = false,
isFiniteHeight = true,
controllerVisible = controllerVisible,
onControllerVisibilityChanged = { controllerVisible.value = it },
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
accountViewModel = accountViewModel,
)
}
}
AnimatedVisibility(
visible = controllerVisible.value,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
Row(
modifier =
Modifier
.padding(horizontal = Size15dp, vertical = Size10dp)
.statusBarsPadding()
.systemBarsPadding()
.fillMaxWidth(),
horizontalArrangement = spacedBy(Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo(
content = imageUrl,
roundedCorner = false,
isFiniteHeight = true,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
)
}
}
Spacer(modifier = Modifier.weight(1f))
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
val popupExpanded = remember { mutableStateOf(false) }
AnimatedVisibility(
visible = controllerVisible.value,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
Row(
modifier =
Modifier
.padding(horizontal = Size15dp, vertical = Size10dp)
.statusBarsPadding()
.systemBarsPadding()
.fillMaxWidth(),
horizontalArrangement = spacedBy(Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = { popupExpanded.value = true },
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
}
if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
val localContext = LocalContext.current
Spacer(modifier = Modifier.weight(1f))
val scope = rememberCoroutineScope()
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
if (myContent is MediaUrlImage || myContent is MediaLocalImage) {
val popupExpanded = remember { mutableStateOf(false) }
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
scope.launch {
saveMediaToGallery(myContent, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
}
OutlinedButton(
onClick = { popupExpanded.value = true },
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
scope.launch {
saveMediaToGallery(myContent, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
}
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
scope.launch(Dispatchers.IO) {
saveMediaToGallery(myContent, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
scope.launch(Dispatchers.IO) {
saveMediaToGallery(myContent, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
} else {
writeStoragePermissionState.launchPermissionRequest()
Icon(
imageVector = Icons.Default.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.save_to_gallery),
)
}
},
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.save_to_gallery),
)
}
}
}
}
@@ -347,11 +354,8 @@ private fun RenderImageOrVideo(
roundedCorner: Boolean,
isFiniteHeight: Boolean,
controllerVisible: MutableState<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onToggleControllerVisibility: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback = remember { mutableStateOf(true) }
val contentScale =
if (isFiniteHeight) {
ContentScale.Fit
@@ -368,9 +372,7 @@ private fun RenderImageOrVideo(
.zoomable(
rememberZoomState(),
onTap = {
if (onToggleControllerVisibility != null) {
onToggleControllerVisibility()
}
controllerVisible.value = !controllerVisible.value
},
)
@@ -412,8 +414,9 @@ private fun RenderImageOrVideo(
authorName = content.authorName,
borderModifier = borderModifier,
contentScale = contentScale,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
nostrUriCallback = content.uri,
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
)
}
@@ -426,9 +429,7 @@ private fun RenderImageOrVideo(
.zoomable(
rememberZoomState(),
onTap = {
if (onToggleControllerVisibility != null) {
onToggleControllerVisibility()
}
controllerVisible.value = !controllerVisible.value
},
)
@@ -471,8 +472,9 @@ private fun RenderImageOrVideo(
authorName = content.authorName,
borderModifier = borderModifier,
contentScale = contentScale,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
nostrUriCallback = content.uri,
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
)
}

View File

@@ -166,9 +166,7 @@ fun ZoomableContentView(
roundedCorner = roundedCorner,
contentScale = contentScale,
nostrUriCallback = content.uri,
onDialog = {
dialogOpen = true
},
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
)
}
@@ -701,7 +699,6 @@ fun ShareMediaAction(
) {
if (content is MediaUrlContent) {
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
videoUri = content.url,
postNostrUri = content.uri,
@@ -711,10 +708,10 @@ fun ShareMediaAction(
mimeType = content.mimeType,
onDismiss = onDismiss,
content = content,
accountViewModel = accountViewModel,
)
} else if (content is MediaPreloadedContent) {
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
videoUri = content.localFile?.toUri().toString(),
postNostrUri = content.uri,
@@ -724,6 +721,7 @@ fun ShareMediaAction(
mimeType = content.mimeType,
onDismiss = onDismiss,
content = content,
accountViewModel = accountViewModel,
)
}
}
@@ -731,7 +729,6 @@ fun ShareMediaAction(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ShareMediaAction(
accountViewModel: AccountViewModel,
popupExpanded: MutableState<Boolean>,
videoUri: String?,
postNostrUri: String?,
@@ -741,6 +738,7 @@ fun ShareMediaAction(
mimeType: String?,
onDismiss: () -> Unit,
content: BaseMediaContent? = null,
accountViewModel: AccountViewModel,
) {
val scope = accountViewModel.viewModelScope

View File

@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import android.graphics.Rect
import androidx.annotation.OptIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -36,7 +36,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -44,23 +44,34 @@ 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.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedSaveButton
import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedShareButton
import com.vitorpamplona.amethyst.service.playback.composable.controls.MuteButton
import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureInPictureButton
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -140,6 +151,7 @@ fun RenderAudioWithWaveform(
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
Modifier.fillMaxWidth().height(100.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GetMediaItem(
@@ -189,35 +201,22 @@ fun RenderVoicePlayer(
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
Box(modifier = borderModifier) {
AndroidView(
ContentFrame(
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we already know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = true
hideController()
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
},
)
}
},
.height(100.dp)
.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
controllerState.visibility.bounds = boundRect
}.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null, // to prevent the ripple from the tap
) { controllerVisible.value = !controllerVisible.value },
player = controllerState.controller,
keepContentOnReset = true,
surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts.
)
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
@@ -225,71 +224,38 @@ fun RenderVoicePlayer(
waveform?.let { Waveform(it, controllerState, Modifier) }
}
RenderControlButtons(
mediaItem.src,
controllerState,
controllerVisible,
Modifier.align(Alignment.TopEnd),
accountViewModel,
RenderTopButtonsForVoice(
mediaData = mediaItem.src,
controllerState = controllerState,
controllerVisible = controllerVisible,
modifier = Modifier.align(Alignment.TopEnd),
accountViewModel = accountViewModel,
)
}
}
@OptIn(UnstableApi::class)
@Composable
fun PlayPauseButton(controllerState: MediaControllerState) {
val controller = controllerState.controller
var isPlaying by remember(controller) {
mutableStateOf(controllerState.isPlaying())
}
val state = rememberPlayPauseButtonState(controllerState.controller)
if (controller != null) {
val view = LocalView.current
// Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = controller, key2 = view) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (view.keepScreenOn != playing) {
view.keepScreenOn = playing
}
isPlaying = playing
}
fun destroy() {
if (view.keepScreenOn) {
view.keepScreenOn = false
}
isPlaying = false
}
}
controller.addListener(listener)
onDispose {
controller.removeListener(listener)
listener.destroy()
// Play/Pause Button
IconButton(
onClick = {
if (controllerState.controller.isPlaying) {
controllerState.controller.pause()
} else {
controllerState.controller.play()
}
}
// Play/Pause Button
IconButton(
onClick = {
if (controller.isPlaying) {
controller.pause()
} else {
controller.play()
}
},
modifier = Size75Modifier,
) {
Icon(
imageVector = if (isPlaying) Icons.Default.PauseCircleOutline else Icons.Default.PlayCircleOutline,
contentDescription = if (isPlaying) stringResource(R.string.pause) else stringResource(R.string.play),
modifier = Size50Modifier,
tint = Color.White,
)
}
},
modifier = Size75Modifier,
) {
Icon(
imageVector = if (state.showPlay) Icons.Default.PlayCircleOutline else Icons.Default.PauseCircleOutline,
contentDescription = if (state.showPlay) stringResource(R.string.play) else stringResource(R.string.pause),
modifier = Size50Modifier,
tint = Color.White,
)
}
}
@@ -343,3 +309,90 @@ fun RenderAudioFromIMeta(
nav = nav,
)
}
@Composable
fun RenderTopButtonsForVoice(
mediaData: MediaItemData,
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current.getActivity()
RenderTopButtonsForVoice(
mediaData = mediaData,
controllerVisible = controllerVisible,
startingMuteState = controllerState.controller.volume < 0.001,
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 = {
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
},
modifier = modifier,
accountViewModel = accountViewModel,
)
}
@Composable
fun RenderTopButtonsForVoice(
mediaData: MediaItemData,
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
onMuteClick: (Boolean) -> Unit,
onPictureInPictureClick: () -> Unit,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
Row(modifier) {
if (!isLiveStreaming(mediaData.videoUri)) {
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
ShareMediaAction(
popupExpanded = popupExpanded,
videoUri = mediaData.videoUri,
postNostrUri = mediaData.callbackUri,
blurhash = null,
dim = null,
hash = null,
mimeType = mediaData.mimeType,
onDismiss = toggle,
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
accountViewModel = accountViewModel,
)
}
AnimatedSaveButton(controllerVisible) { context ->
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
}
} else {
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
ShareMediaAction(
popupExpanded = popupExpanded,
videoUri = mediaData.videoUri,
postNostrUri = mediaData.callbackUri,
blurhash = null,
dim = null,
hash = null,
mimeType = mediaData.mimeType,
onDismiss = toggle,
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
accountViewModel = accountViewModel,
)
}
}
PictureInPictureButton(
controllerVisible = controllerVisible,
onClick = onPictureInPictureClick,
)
MuteButton(
controllerVisible = controllerVisible,
startingMuteState = startingMuteState,
toggle = onMuteClick,
)
}
}

View File

@@ -221,8 +221,9 @@ val NotificationIconModifierSmaller = Modifier.width(55.dp).padding(end = 4.dp)
val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp)
val ChatHeadlineBorders = StdPadding
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val VolumeBottomIconSize = Modifier.size(60.dp).padding(5.dp)
val PinBottomIconSize = Modifier.size(60.dp).padding(5.dp)
val PlayIconSize = Modifier.size(110.dp).padding(10.dp)
val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
val CashuCardBorders = Modifier.fillMaxWidth().padding(10.dp).clip(shape = QuoteBorder)
@@ -375,6 +376,7 @@ val QuickActionPopupShadow = Modifier.shadow(elevation = Size6dp, shape = Smalle
val SpacedBy2dp = Arrangement.spacedBy(Size2dp)
val SpacedBy5dp = Arrangement.spacedBy(Size5dp)
val SpacedBy10dp = Arrangement.spacedBy(Size10dp)
val SpacedBy55dp = Arrangement.spacedBy(Size55dp)
val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp)