From f2410a6921b9cd8cc530fc1c04e915b3067ddc8f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Feb 2026 17:28:41 -0500 Subject: [PATCH] - 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 --- amethyst/src/main/AndroidManifest.xml | 1 - .../composable/ControlWhenPlayerIsActive.kt | 7 +- .../playback/composable/GetVideoController.kt | 230 ++-------------- .../composable/LoadThumbAndThenVideoView.kt | 2 +- .../composable/MediaControllerState.kt | 30 +- .../playback/composable/RenderVideoPlayer.kt | 114 ++++---- .../service/playback/composable/VideoView.kt | 20 +- .../playback/composable/VideoViewInner.kt | 12 +- .../composable/controls/FullScreenButton.kt | 97 +++++++ .../HorizontalLinearProgressIndicator.kt | 132 +++++++++ .../composable/controls/MuteButton.kt | 22 +- .../controls/PictureInPictureButton.kt | 39 ++- .../composable/controls/PlayPauseButton.kt | 130 +++++++++ .../controls/PlaybackSpeedPopUpButton.kt | 167 ++++++++++++ .../controls/PositionAndDurationText.kt | 82 ++++++ .../composable/controls/RenderBottomInfo.kt | 78 ++++++ .../controls/RenderCenterButtons.kt} | 37 ++- .../controls/RenderControlButtons.kt | 82 ------ .../composable/controls/RenderTopButtons.kt | 167 ++++++++++++ .../composable/controls/SaveMediaButton.kt | 21 +- .../composable/controls/ShareButton.kt | 30 +- .../mainVideo/VideoPlayerActiveMutex.kt | 30 +- .../playback/composable/wavefront/Waveform.kt | 33 +-- .../service/playback/pip/BackgroundMedia.kt | 33 ++- .../service/playback/pip/PiPFromIntents.kt | 81 ++++++ .../service/playback/pip/PictureInPicture.kt | 44 ++- .../service/playback/pip/PipVideoActivity.kt | 45 ++- .../service/playback/pip/PipVideoView.kt | 149 +++++----- .../playback/pip/VideoPlayerActions.kt | 95 +++++++ .../playback/playerPool/ExoPlayerPool.kt | 22 +- .../playback/playerPool/MediaSessionPool.kt | 15 +- .../playback/service/PlaybackService.kt | 4 +- .../playback/service/PlaybackServiceClient.kt | 120 ++++---- .../ui/components/ZoomableContentDialog.kt | 256 +++++++++--------- .../ui/components/ZoomableContentView.kt | 10 +- .../amethyst/ui/note/types/VoiceTrack.kt | 239 +++++++++------- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 6 +- 37 files changed, 1782 insertions(+), 900 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullScreenButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderBottomInfo.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/{pip/ActivityExt.kt => composable/controls/RenderCenterButtons.kt} (52%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PiPFromIntents.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/VideoPlayerActions.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 9bbfce57b..7339ba1bb 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -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"> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt index 40a36abbf..79119551f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt @@ -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, + automaticallyStartPlayback: Boolean, isClosestToTheCenterOfTheScreen: MutableState, ) { - 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 02c05ca05..3375b7919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt index a16aeea85..d8f4f68d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt @@ -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(false, null)) } val context = LocalContext.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt index 42b49036f..b1a4f3b51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt @@ -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 = mutableStateOf(false), - // this will be false if the screen leaves before the controller connection comes back from the service. - val active: MutableState = mutableStateOf(true), - // this will be set to own when the controller is ready - val readyToDisplay: MutableState = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index cef1e7ef5..2de1333a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -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 = 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)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index a5658a15a..f7e31c162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 6f560f0c7..68e285b64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -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, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onDialog: ((Boolean) -> Unit)? = null, + automaticallyStartPlayback: Boolean, + controllerVisible: MutableState = 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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullScreenButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullScreenButton.kt new file mode 100644 index 000000000..280f86c9f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullScreenButton.kt @@ -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, + 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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt new file mode 100644 index 000000000..44b0ec8d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt index 60f95ceaf..8f189888f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt @@ -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, 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), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt index 885f49a1b..f3dc6f4ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt @@ -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, - 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), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt new file mode 100644 index 000000000..0160cf2cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt @@ -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, + 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), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt new file mode 100644 index 000000000..1c3b53f2f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt @@ -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 = 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 = 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, + 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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt new file mode 100644 index 000000000..bafa1a06a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderBottomInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderBottomInfo.kt new file mode 100644 index 000000000..905041c3d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderBottomInfo.kt @@ -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, + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt similarity index 52% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt index da0303f44..45c554f35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -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, + 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 + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt deleted file mode 100644 index 888a6ddb0..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt +++ /dev/null @@ -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, - 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)) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt new file mode 100644 index 000000000..e103ec1e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -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, + 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, + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt index f31531c04..11e6111fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt @@ -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, - 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), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt index e26e6db55..4e96d9f32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt @@ -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, - modifier: Modifier, + modifier: Modifier = Modifier, innerAction: @Composable (MutableState, () -> Unit) -> Unit, ) { AnimatedVisibility( @@ -64,13 +80,13 @@ fun AnimatedShareButton( @Composable fun ShareButton(innerAction: @Composable (MutableState, () -> 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt index b34df0c0f..1a15d7d1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt @@ -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() +val trackingVideos = mutableSetOf() /** * 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) -> Unit, ) { // Is the current video the closest to the center? - val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt index b5bc0648f..71d9d3a16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt @@ -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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt index 477a23010..a316de304 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt @@ -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(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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PiPFromIntents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PiPFromIntents.kt new file mode 100644 index 000000000..815338ac8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PiPFromIntents.kt @@ -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 { + val activity = LocalContext.current.getActivity() + + val videoData = + remember { + mutableStateOf(activity.processIntentForPiP(activity.intent)) + } + + val scope = rememberCoroutineScope() + + DisposableEffect(activity) { + val consumer = + Consumer { 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt index 04aadfe74..ba836e13c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt @@ -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 { 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 379dd42ff..f69ce32c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -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(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 14a51f341..7a44dbccb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -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 -> - 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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/VideoPlayerActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/VideoPlayerActions.kt new file mode 100644 index 000000000..5f0a1afa4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/VideoPlayerActions.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index a8496a07f..c8d4f3082 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -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. } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 7cd6c1dfc..48811b130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -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++ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 401b37bcd..b69eaefae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 15ff30a61..1b3bd3934 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -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}") + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index a5a2dfab9..b74937b6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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, - 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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 751a98db3..f8cc1c82a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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, videoUri: String?, postNostrUri: String?, @@ -741,6 +738,7 @@ fun ShareMediaAction( mimeType: String?, onDismiss: () -> Unit, content: BaseMediaContent? = null, + accountViewModel: AccountViewModel, ) { val scope = accountViewModel.viewModelScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 976916d38..14f5c3f5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -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, + 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, + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index ce07d2449..f1bd3b144 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -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)