diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 952c8b424..af1a6163c 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -69,6 +69,7 @@ android:launchMode="singleTop" android:windowSoftInputMode="adjustResize" android:configChanges="orientation|screenSize|screenLayout" + android:taskAffinity=".service.playback.pip.PipVideoActivity" android:theme="@style/Theme.Amethyst"> @@ -121,6 +122,17 @@ android:screenOrientation="fullSensor" tools:replace="screenOrientation" /> + + , isClosestToTheCenterOfTheScreen: MutableState, ) { - val controller = mediaControllerState.controller.value ?: return + val controller = mediaControllerState.controller ?: return LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) { // active means being fully visible @@ -58,9 +59,7 @@ fun ControlWhenPlayerIsActive( // Pauses the video when it becomes invisible. // Destroys the video later when it Disposes the element // meanwhile if the user comes back, the position in the track is saved. - if (!mediaControllerState.keepPlaying.value) { - controller.pause() - } + 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 200f72c52..a0fa2515c 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 @@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.service.playback.composable import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.State 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.MediaItem import androidx.media3.common.Player +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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -39,35 +39,33 @@ import java.util.concurrent.atomic.AtomicBoolean @Composable fun GetVideoController( - mediaItem: State, - videoUri: String, - proxyPort: Int?, + mediaItem: LoadedMediaItem, muted: Boolean = false, inner: @Composable (mediaControllerState: MediaControllerState) -> Unit, ) { val context = LocalContext.current - val onlyOnePreparing = AtomicBoolean() + val onlyOnePreparing = remember { AtomicBoolean() } - val controllerId = remember(videoUri) { BackgroundMedia.backgroundOrNewController(videoUri) } + val controllerId = remember(mediaItem.src.videoUri) { MediaControllerState() } - controllerId.composed.value = true + controllerId.composed = true val scope = rememberCoroutineScope() // Prepares a VideoPlayer from the foreground service. - DisposableEffect(key1 = videoUri) { + 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 { - Log.d("PlaybackService", "Preparing Video ${controllerId.id} $videoUri") + Log.d("PlaybackService", "Preparing Video ${controllerId.id} $mediaItem.src.videoUri") PlaybackServiceClient.prepareController( controllerId, - videoUri, - proxyPort, + mediaItem.src.videoUri, + mediaItem.src.proxyPort, context, ) { controllerId -> scope.launch(Dispatchers.Main) { @@ -82,16 +80,16 @@ fun GetVideoController( if (!controllerId.isPlaying()) { if (BackgroundMedia.isPlaying()) { // There is a video playing, start this one on mute. - controllerId.controller.value?.volume = 0f + 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.value?.volume = if (muted) 0f else 1f + controllerId.controller?.volume = if (muted) 0f else 1f } } - controllerId.controller.value?.setMediaItem(mediaItem.value) - controllerId.controller.value?.prepare() + controllerId.controller?.setMediaItem(mediaItem.item) + controllerId.controller?.prepare() // checks if the player is still active after requesting to load if (!controllerId.isActive()) { @@ -114,7 +112,7 @@ fun GetVideoController( } } else { // has been loaded. prepare to play. This happens when the background video switches screens. - controllerId.controller.value?.let { + controllerId.controller?.let { scope.launch { // checks if the player is still active after requesting to load if (!controllerId.isActive()) { @@ -123,7 +121,7 @@ fun GetVideoController( } if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { - Log.d("PlaybackService", "Preparing Existing Video $videoUri ") + Log.d("PlaybackService", "Preparing Existing Video ${mediaItem.src.videoUri} ") if (it.isPlaying) { // There is a video playing, start this one on mute. @@ -134,8 +132,8 @@ fun GetVideoController( it.volume = if (muted) 0f else 1f } - if (mediaItem.value != it.currentMediaItem) { - it.setMediaItem(mediaItem.value) + if (mediaItem.item != it.currentMediaItem) { + it.setMediaItem(mediaItem.item) } it.prepare() @@ -151,8 +149,8 @@ fun GetVideoController( } onDispose { - controllerId.composed.value = false - if (!controllerId.keepPlaying.value) { + controllerId.composed = false + if (!controllerId.pictureInPictureActive.value) { PlaybackServiceClient.removeController(controllerId) } } @@ -164,17 +162,17 @@ fun GetVideoController( val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - controllerId.composed.value = true + 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.controller.value == null) { + if (controllerId.needsController()) { if (!onlyOnePreparing.getAndSet(true)) { scope.launch(Dispatchers.Main) { - Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} $videoUri ") + Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ") PlaybackServiceClient.prepareController( controllerId, - videoUri, - proxyPort, + mediaItem.src.videoUri, + mediaItem.src.proxyPort, context, ) { controllerId -> scope.launch(Dispatchers.Main) { @@ -190,16 +188,16 @@ fun GetVideoController( if (!controllerId.isPlaying()) { if (BackgroundMedia.isPlaying()) { // There is a video playing, start this one on mute. - controllerId.controller.value?.volume = 0f + 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.value?.volume = if (muted) 0f else 1f + controllerId.controller?.volume = if (muted) 0f else 1f } } - controllerId.controller.value?.setMediaItem(mediaItem.value) - controllerId.controller.value?.prepare() + controllerId.controller?.setMediaItem(mediaItem.item) + controllerId.controller?.prepare() // checks if the player is still active after requesting to load if (!controllerId.isActive()) { @@ -224,11 +222,8 @@ fun GetVideoController( } } if (event == Lifecycle.Event.ON_PAUSE) { - controllerId.composed.value = false - if (!controllerId.keepPlaying.value) { - // Stops and releases the media. - PlaybackServiceClient.removeController(controllerId) - } + controllerId.composed = false + PlaybackServiceClient.removeController(controllerId) } } @@ -237,7 +232,7 @@ fun GetVideoController( } if (controllerId.readyToDisplay.value && controllerId.active.value) { - controllerId.controller.value?.let { + controllerId.controller?.let { inner(controllerId) } } 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 1ece014c0..24131f5a7 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 @@ -20,6 +20,7 @@ */ 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 @@ -31,21 +32,29 @@ class MediaControllerState( // each composable has an ID. val id: String = UUID.randomUUID().toString(), // This is filled after the controller returns from this class - val controller: MutableState = mutableStateOf(null), + var controller: MediaController? = null, // this set's the stage to keep playing on the background or not when the user leaves the screen - val keepPlaying: MutableState = mutableStateOf(false), + 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 - val composed: MutableState = mutableStateOf(false), + var composed: Boolean = false, + // visibility onscreen + val visibility: VisibilityData = VisibilityData(), ) { - fun isPlaying() = controller.value?.isPlaying == true + fun isPlaying() = controller?.isPlaying == true - fun currrentMedia() = controller.value?.currentMediaItem?.mediaId + fun currrentMedia() = controller?.currentMediaItem?.mediaId fun isActive() = active.value == true - fun needsController() = controller.value == null + fun needsController() = controller == null +} + +@Stable +class VisibilityData { + var bounds: Rect? = null + var distanceToCenter: Float? = null } 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 e570a92ff..597f932e9 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 @@ -36,7 +36,8 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView -import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControls +import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons +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 import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag @@ -44,13 +45,11 @@ import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag @Composable @OptIn(UnstableApi::class) fun RenderVideoPlayer( - videoUri: String, - mimeType: String?, - controller: MediaControllerState, + mediaItem: LoadedMediaItem, + controllerState: MediaControllerState, thumbData: VideoThumb?, showControls: Boolean = true, contentScale: ContentScale, - nostrUriCallback: String?, waveform: WaveformTag? = null, borderModifier: Modifier, videoModifier: Modifier, @@ -58,14 +57,14 @@ fun RenderVideoPlayer( onDialog: ((Boolean) -> Unit)?, accountViewModel: AccountViewModel, ) { - val controllerVisible = remember(controller) { mutableStateOf(false) } + val controllerVisible = remember(controllerState) { mutableStateOf(false) } Box(modifier = borderModifier) { AndroidView( modifier = videoModifier, factory = { context: Context -> PlayerView(context).apply { - player = controller.controller.value + player = controllerState.controller setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) setBackgroundColor(Color.Transparent.toArgb()) setShutterBackgroundColor(Color.Transparent.toArgb()) @@ -88,7 +87,7 @@ fun RenderVideoPlayer( if (showControls) { onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { - controller.controller.value?.pause() + controllerState.controller?.pause() innerOnDialog(it) } } @@ -103,20 +102,18 @@ fun RenderVideoPlayer( }, ) - waveform?.let { Waveform(it, controller, Modifier.align(Alignment.Center)) } + waveform?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) } if (showControls) { - RenderControls( - videoUri, - mimeType, - controller, - nostrUriCallback, + RenderControlButtons( + mediaItem.src, + controllerState, controllerVisible, Modifier.align(Alignment.TopEnd), accountViewModel, ) } else { - controller.controller.value?.volume = 0f + controllerState.controller?.volume = 0f } } } 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 37197b346..7d7ec1a98 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 @@ -123,6 +123,7 @@ fun VideoView( VideoViewInner( videoUri = videoUri, mimeType = mimeType, + aspectRatio = ratio, title = title, thumb = thumb, borderModifier = borderModifier, @@ -169,6 +170,7 @@ fun VideoView( VideoViewInner( videoUri = videoUri, mimeType = mimeType, + aspectRatio = ratio, title = title, thumb = thumb, borderModifier = borderModifier, 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 d34acc377..e560bb155 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 @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.State -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -39,6 +38,7 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true) fun VideoViewInner( videoUri: String, mimeType: String?, + aspectRatio: Float? = null, title: String? = null, thumb: VideoThumb? = null, showControls: Boolean = true, @@ -56,26 +56,31 @@ fun VideoViewInner( // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value } - GetMediaItem(videoUri, title, artworkUri, authorName, nostrUriCallback) { mediaItem -> + GetMediaItem( + videoUri, + title, + artworkUri, + authorName, + nostrUriCallback, + mimeType, + aspectRatio, + proxyPort = + HttpClientManager.getCurrentProxyPort( + accountViewModel.account.shouldUseTorForVideoDownload(videoUri), + ), + ) { mediaItem -> GetVideoController( mediaItem = mediaItem, - videoUri = videoUri, muted = muted, - proxyPort = - HttpClientManager.getCurrentProxyPort( - accountViewModel.account.shouldUseTorForVideoDownload(videoUri), - ), ) { controller -> - VideoPlayerActiveMutex(controller.id) { videoModifier, isClosestToTheCenterOfTheScreen -> + VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen -> ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen) RenderVideoPlayer( - videoUri = videoUri, - mimeType = mimeType, - controller = controller, + mediaItem = mediaItem, + controllerState = controller, thumbData = thumb, showControls = showControls, contentScale = contentScale, - nostrUriCallback = nostrUriCallback, waveform = waveform, borderModifier = borderModifier, videoModifier = videoModifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/KeepPlayingButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/KeepPlayingButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt index b7ef1c84c..1444ae2bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/KeepPlayingButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt @@ -31,26 +31,21 @@ 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 com.vitorpamplona.amethyst.ui.note.LyricsIcon -import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon +import com.vitorpamplona.amethyst.ui.note.EnablePiP import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier @Composable -fun KeepPlayingButton( - keepPlayingStart: MutableState, +fun PictureInPictureButton( controllerVisible: MutableState, modifier: Modifier, - toggle: (Boolean) -> Unit, + onClick: () -> Unit, ) { - val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } - AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, @@ -67,17 +62,10 @@ fun KeepPlayingButton( ) IconButton( - onClick = { - keepPlaying.value = !keepPlaying.value - toggle(keepPlaying.value) - }, + onClick = onClick, modifier = Size50Modifier, ) { - if (keepPlaying.value) { - LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) - } else { - LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground) - } + EnablePiP(Size22Modifier, MaterialTheme.colorScheme.onBackground) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt similarity index 71% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControls.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt index 6257f9a21..4cd487f64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControls.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt @@ -25,79 +25,65 @@ 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.R -import com.vitorpamplona.amethyst.service.playback.composable.BackgroundMedia 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.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.components.ShareImageAction +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 RenderControls( - videoUri: String, - mimeType: String?, +fun RenderControlButtons( + mediaData: MediaItemData, controllerState: MediaControllerState, - nostrUriCallback: String?, controllerVisible: MutableState, buttonPositionModifier: Modifier, accountViewModel: AccountViewModel, ) { MuteButton( controllerVisible, - (controllerState.controller.value?.volume ?: 0f) < 0.001, + (controllerState.controller?.volume ?: 0f) < 0.001, buttonPositionModifier, ) { mute: Boolean -> // makes the new setting the default for new creations. DEFAULT_MUTED_SETTING.value = mute - // if the user unmutes a video and it's not the current playing, switches to that one. - if (!mute && BackgroundMedia.hasBackgroundButNot(controllerState)) { - BackgroundMedia.removeBackgroundControllerIfNotComposed() - } - - controllerState.controller.value?.volume = if (mute) 0f else 1f + controllerState.controller?.volume = if (mute) 0f else 1f } - KeepPlayingButton( - controllerState.keepPlaying, + val context = LocalContext.current.getActivity() + + PictureInPictureButton( controllerVisible, buttonPositionModifier.padding(end = Size55dp), - ) { newKeepPlaying: Boolean -> - // If something else is playing and the user marks this video to keep playing, stops the other - // one. - if (newKeepPlaying) { - BackgroundMedia.switchKeepPlaying(controllerState) - } else { - // if removed from background. - if (BackgroundMedia.isMutex(controllerState)) { - BackgroundMedia.removeBackgroundControllerIfNotComposed() - } - } - - controllerState.keepPlaying.value = newKeepPlaying + ) { + PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context) } - if (!isLiveStreaming(videoUri)) { + if (!isLiveStreaming(mediaData.videoUri)) { AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context -> - saveMediaToGallery(videoUri, mimeType, context, accountViewModel) + saveMediaToGalleryInner(mediaData.videoUri, mediaData.mimeType, context, accountViewModel) } AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle -> - ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle) + ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle) } } else { AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle -> - ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle) + ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle) } } } -private fun saveMediaToGallery( +private fun saveMediaToGalleryInner( videoUri: String?, mimeType: String?, localContext: Context, 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 de1296ac5..b22ad4cef 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 @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -37,15 +36,11 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import kotlin.math.abs // This keeps the position of all visible videos in the current screen. -val trackingVideos = mutableListOf() - -@Stable -class VisibilityData { - var distanceToCenter: Float? = null -} +val trackingVideos = mutableListOf() /** * This function selects only one Video to be active. The video that is closest to the center of the @@ -53,30 +48,29 @@ class VisibilityData { */ @Composable fun VideoPlayerActiveMutex( - controller: String, + controller: MediaControllerState, inner: @Composable (Modifier, MutableState) -> Unit, ) { - val myCache = remember(controller) { VisibilityData() } - // Is the current video the closest to the center? val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) } // Keep track of all available videos. DisposableEffect(key1 = controller) { - trackingVideos.add(myCache) - onDispose { trackingVideos.remove(myCache) } + trackingVideos.add(controller) + onDispose { trackingVideos.remove(controller) } } val videoModifier = remember(controller) { - Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { distanceToCenter -> - myCache.distanceToCenter = distanceToCenter + Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { bounds, distanceToCenter -> + controller.visibility.bounds = bounds + controller.visibility.distanceToCenter = distanceToCenter if (distanceToCenter != null) { // finds out of the current video is the closest to the center. var newActive = true for (video in trackingVideos) { - val videoPos = video.distanceToCenter + val videoPos = video.visibility.distanceToCenter if (videoPos != null && videoPos < distanceToCenter) { newActive = false break @@ -99,16 +93,21 @@ fun VideoPlayerActiveMutex( inner(videoModifier, isClosestToTheCenterOfTheScreen) } -fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = +fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier = composed { val view = LocalView.current onGloballyPositioned { coordinates -> - onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view)) + val bounds = coordinates.boundsInWindow() + val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) + onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view)) } } -fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? { +fun LayoutCoordinates.getDistanceToVertCenterIfVisible( + bounds: Rect, + view: View, +): Float? { if (!isAttached) return null // Window relative bounds of our compose root view that are visible on the screen val globalRootRect = Rect() @@ -117,8 +116,6 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? { return null } - val bounds = boundsInWindow() - if (bounds.isEmpty) return null // Make sure we are completely in bounds. @@ -129,7 +126,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? { bounds.bottom <= globalRootRect.bottom ) { return abs( - ((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2), + ((bounds.top + bounds.bottom) / 2.0f) - ((globalRootRect.top + globalRootRect.bottom) / 2.0f), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt index 9f3802a32..da3bbf685 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt @@ -21,11 +21,8 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.media3.common.MediaItem import com.vitorpamplona.amethyst.commons.compose.produceCachedState val mediaItemCache = MediaItemCache() @@ -33,11 +30,14 @@ val mediaItemCache = MediaItemCache() @Composable fun GetMediaItem( videoUri: String, - title: String?, - artworkUri: String?, - authorName: String?, - callbackUri: String?, - inner: @Composable (State) -> Unit, + title: String? = null, + artworkUri: String? = null, + authorName: String? = null, + callbackUri: String? = null, + mimeType: String? = null, + aspectRatio: Float? = null, + proxyPort: Int? = null, + inner: @Composable (LoadedMediaItem) -> Unit, ) { val data = remember(videoUri) { @@ -47,6 +47,9 @@ fun GetMediaItem( title = title, artworkUri = artworkUri, callbackUri = callbackUri, + mimeType = mimeType, + aspectRatio = aspectRatio, + proxyPort = proxyPort, ) } @@ -56,12 +59,11 @@ fun GetMediaItem( @Composable fun GetMediaItem( data: MediaItemData, - inner: @Composable (State) -> Unit, + inner: @Composable (LoadedMediaItem) -> Unit, ) { val mediaItem by produceCachedState(cache = mediaItemCache, key = data) mediaItem?.let { - val myState = remember(data.videoUri) { mutableStateOf(it) } - inner(myState) + inner(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt index 06097ec37..4a6324d69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt @@ -27,28 +27,31 @@ import androidx.media3.common.MediaMetadata import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import kotlin.coroutines.cancellation.CancellationException -class MediaItemCache : GenericBaseCache(20) { - override suspend fun compute(key: MediaItemData): MediaItem = - MediaItem - .Builder() - .setMediaId(key.videoUri) - .setUri(key.videoUri) - .setMediaMetadata( - MediaMetadata - .Builder() - .setArtist(key.authorName?.ifBlank { null }) - .setTitle(key.title?.ifBlank { null } ?: key.videoUri) - .setExtras( - Bundle().apply { - putString("callbackUri", key.callbackUri) - }, - ).setArtworkUri( - try { - key.artworkUri?.toUri() - } catch (e: Exception) { - if (e is CancellationException) throw e - null - }, - ).build(), - ).build() +class MediaItemCache : GenericBaseCache(20) { + override suspend fun compute(key: MediaItemData): LoadedMediaItem = + LoadedMediaItem( + key, + MediaItem + .Builder() + .setMediaId(key.videoUri) + .setUri(key.videoUri) + .setMediaMetadata( + MediaMetadata + .Builder() + .setArtist(key.authorName?.ifBlank { null }) + .setTitle(key.title?.ifBlank { null } ?: key.videoUri) + .setExtras( + Bundle().apply { + putString("callbackUri", key.callbackUri) + }, + ).setArtworkUri( + try { + key.artworkUri?.toUri() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + }, + ).build(), + ).build(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt index e342fe7be..ddee35637 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem import androidx.compose.runtime.Immutable +import androidx.media3.common.MediaItem @Immutable data class MediaItemData( @@ -29,4 +30,13 @@ data class MediaItemData( val title: String? = null, val artworkUri: String? = null, val callbackUri: String? = null, + val mimeType: String? = null, + val aspectRatio: Float? = null, + val proxyPort: Int? = null, +) + +@Immutable +class LoadedMediaItem( + val src: MediaItemData, + val item: MediaItem, ) 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 9498d3201..91cf55c90 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 @@ -57,7 +57,7 @@ fun Waveform( val restartFlow = remember { mutableIntStateOf(0) } // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = mediaControllerState.controller.value) { + DisposableEffect(key1 = mediaControllerState.controller) { val listener = object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -69,12 +69,12 @@ fun Waveform( } } - mediaControllerState.controller.value?.addListener(listener) - onDispose { mediaControllerState.controller.value?.removeListener(listener) } + mediaControllerState.controller?.addListener(listener) + onDispose { mediaControllerState.controller?.removeListener(listener) } } LaunchedEffect(key1 = restartFlow.intValue) { - mediaControllerState.controller.value?.let { + mediaControllerState.controller?.let { pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value } } } 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/pip/ActivityExt.kt new file mode 100644 index 000000000..9574dd6f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2024 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.graphics.Rect + +fun Activity.enterPipMode( + ratio: Float?, + bounds: Rect?, +) { + if (!isInPictureInPictureMode) { + enterPictureInPictureMode(makePipParams(ratio, bounds)) + } else { + setPictureInPictureParams(makePipParams(ratio, bounds)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/BackgroundMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/BackgroundMedia.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt index 13dc54777..a2aaabdd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/BackgroundMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt @@ -18,20 +18,37 @@ * 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 +package com.vitorpamplona.amethyst.service.playback.pip -import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient.removeController +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import kotlinx.coroutines.flow.MutableStateFlow +/** + * Copyright (c) 2024 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. + */ object BackgroundMedia { // background playing mutex. val bgInstance = MutableStateFlow(null) - private fun hasInstance() = bgInstance.value != null - - private fun isComposed() = bgInstance.value?.composed?.value == true - - private fun isUri(videoUri: String): Boolean = videoUri == bgInstance.value?.currrentMedia() + fun hasInstance() = bgInstance.value != null fun isPlaying() = bgInstance.value?.isPlaying() == true @@ -39,35 +56,18 @@ object BackgroundMedia { fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState) - fun backgroundOrNewController(videoUri: String): MediaControllerState { - // allows only the first composable with the url of the video to match. - return if (isUri(videoUri) && !isComposed()) { - bgInstance.value ?: MediaControllerState() - } else { - MediaControllerState() - } - } - fun removeBackgroundControllerAndReleaseIt() { bgInstance.value?.let { - removeController(it) - bgInstance.tryEmit(null) - } - } - - fun removeBackgroundControllerIfNotComposed() { - bgInstance.value?.let { - if (!it.composed.value) { - removeController(it) - } - bgInstance.tryEmit(null) + PlaybackServiceClient.removeController(it) + clearBackground() } } fun switchKeepPlaying(mediaControllerState: MediaControllerState) { - if (hasInstance() && !isMutex(mediaControllerState)) { - removeBackgroundControllerIfNotComposed() - } bgInstance.tryEmit(mediaControllerState) } + + fun clearBackground() { + bgInstance.tryEmit(null) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt new file mode 100644 index 000000000..1b35c8d77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2024 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.graphics.Rect +import android.os.Bundle +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData +import com.vitorpamplona.quartz.utils.ensure + +class IntentExtras { + companion object { + fun loadBounds(intent: Bundle?): Rect? { + ensure(intent != null) { return null } + + val left = intent.getInt("boundLeft") + val right = intent.getInt("boundRight") + val top = intent.getInt("boundTop") + val bottom = intent.getInt("boundBottom") + + return if (left > 0 && right > 0 && top > 0 && bottom > 0) { + Rect(left, top, right, bottom) + } else { + null + } + } + + fun loadBundle(intent: Bundle?): MediaItemData? { + ensure(intent != null) { return null } + val uri = intent.getString("videoUri") ?: return null + + val ratio = intent.getFloat("aspectRatio") + val port = intent.getInt("proxyPort") + + return MediaItemData( + videoUri = uri, + authorName = intent.getString("authorName"), + title = intent.getString("title"), + artworkUri = intent.getString("artworkUri"), + callbackUri = intent.getString("callbackUri"), + mimeType = intent.getString("mimeType"), + aspectRatio = if (ratio > 0) ratio else null, + proxyPort = if (port > 0) port else null, + ) + } + + fun createBundle( + data: MediaItemData, + bounds: Rect?, + ): Bundle = + Bundle().apply { + putString("videoUri", data.videoUri) + data.authorName?.let { putString("authorName", it) } + data.title?.let { putString("title", it) } + data.artworkUri?.let { putString("artworkUri", it) } + data.callbackUri?.let { putString("callbackUri", it) } + data.mimeType?.let { putString("mimeType", it) } + data.aspectRatio?.let { putFloat("aspectRatio", it) } + data.proxyPort?.let { putInt("proxyPort", it) } + + bounds?.let { putInt("boundLeft", it.left) } + bounds?.let { putInt("boundRight", it.right) } + bounds?.let { putInt("boundTop", it.top) } + bounds?.let { putInt("boundBottom", it.bottom) } + } + } +} 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 new file mode 100644 index 000000000..dd2626cbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2024 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.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?, + bounds: Rect?, +): PictureInPictureParams = + PictureInPictureParams + .Builder() + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setAutoEnterEnabled(true) + } + bounds?.let { setSourceRectHint(bounds) } + 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 + } + activity.addOnPictureInPictureModeChangedListener( + observer, + ) + onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } + } + return pipMode +} 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 new file mode 100644 index 000000000..4d276595d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2024 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.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.media3.common.util.UnstableApi +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData + +class PipVideoActivity : ComponentActivity() { + @OptIn(UnstableApi::class) + 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() + } + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration, + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + } + + override fun onStop() { + super.onStop() + finishAndRemoveTask() + } + + override fun finish() { + finishAndRemoveTask() + super.finish() + } + + override fun onBackPressed() { + super.onBackPressed() + finishAndRemoveTask() + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + finishAndRemoveTask() + } + + companion object { + fun callIn( + videoData: MediaItemData, + videoBounds: Rect?, + context: Context, + ) { + val options = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityOptions.makeLaunchIntoPip( + makePipParams(videoData.aspectRatio, videoBounds), + ) + } else { + ActivityOptions.makeBasic() + } + options.setLaunchBounds(videoBounds) + + context.startActivity( + Intent(context, 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 new file mode 100644 index 000000000..9b9e0c661 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2024 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.content.Context +import android.content.Intent +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Box +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 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.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.ui.components.getActivity + +@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) + } + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun PipVideo(controller: MediaControllerState) { + DisposableEffect(controller) { + BackgroundMedia.switchKeepPlaying(controller) + onDispose { + BackgroundMedia.clearBackground() + } + } + + val ratio = + controller.currrentMedia()?.let { + MediaAspectRatioCache.get(it) + } + + val modifier = + 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 + } + }, + ) + } +} 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 8c3336473..e84cfff7a 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 @@ -165,6 +165,8 @@ class MediaSessionPool( fun playingContent() = playingMap.values + fun getSession(id: String) = cache.get(id)?.session + class MediaSessionCallback( val pool: MediaSessionPool, ) : MediaSession.Callback { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt index eec0355c8..ea4588525 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt @@ -34,11 +34,7 @@ class AspectRatioCacher( mediaItem: MediaItem?, reason: Int, ) { - if (mediaItem == null) { - currentUrl = null - } else { - currentUrl = mediaItem.mediaId - } + currentUrl = mediaItem?.mediaId } override fun onVideoSizeChanged(videoSize: VideoSize) { 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 907a2558f..0f0f5dfb7 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 @@ -29,6 +29,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool @@ -91,37 +92,46 @@ class PlaybackService : MediaSessionService() { // Updates any new player ready super.onUpdateNotification(session, startInForegroundRequired) - val proxyPlaying = poolWithProxy?.playingContent() + // playback controllers control the last notification updated. + // this procedure re-updates the notification to make sure it aligns + // with users expectation on which playback they decide to control: + // 1. If no video is being played, play the picture in picture if there. + // 2. If there are videos being played the order is: + // 2. a. Picture in picture if playing + // 2. b. On screen video with volume on + // 2. c. On screen video with volume off. - // Overrides the notification with any player actually playing - proxyPlaying?.forEach { - if (it.session.player.isPlaying) { + val playing = (poolWithProxy?.playingContent() ?: emptyList()) + (poolNoProxy?.playingContent() ?: emptyList()) + + // if nothing is pl + if (playing.isEmpty() && BackgroundMedia.hasInstance()) { + BackgroundMedia.bgInstance.value?.id?.let { id -> + (poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let { + super.onUpdateNotification(it, startInForegroundRequired) + } + } + return + } + + playing.forEachIndexed { idx, it -> + if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance.value?.id) { super.onUpdateNotification(it.session, startInForegroundRequired) + return } } - // Overrides again with playing with audio - proxyPlaying?.forEach { + playing.forEachIndexed { idx, it -> if (it.session.player.isPlaying && it.session.player.volume > 0) { super.onUpdateNotification(it.session, startInForegroundRequired) + return } } - val noProxyPlaying = poolNoProxy?.playingContent() - - // Overrides the notification with any player actually playing - noProxyPlaying?.forEach { + playing.forEachIndexed { idx, it -> if (it.session.player.isPlaying) { super.onUpdateNotification(it.session, startInForegroundRequired) } } - - // Overrides again with playing with audio - noProxyPlaying?.forEach { - if (it.session.player.isPlaying && it.session.player.volume > 0) { - super.onUpdateNotification(it.session, startInForegroundRequired) - } - } } // Return a MediaSession to link with the MediaController that is making 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 d6a3ffecd..321b82742 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 @@ -41,10 +41,10 @@ object PlaybackServiceClient { mediaControllerState.active.value = false mediaControllerState.readyToDisplay.value = false - val myController = mediaControllerState.controller.value + val myController = mediaControllerState.controller // release when can if (myController != null) { - mediaControllerState.controller.value = null + mediaControllerState.controller = null GlobalScope.launch(Dispatchers.Main) { // myController.pause() // myController.stop() @@ -61,6 +61,8 @@ object PlaybackServiceClient { context: Context, onReady: (MediaControllerState) -> Unit, ) { + val appContext = context.applicationContext + mediaControllerState.active.value = true try { @@ -74,11 +76,11 @@ object PlaybackServiceClient { } } - val session = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java)) val controllerFuture = MediaController - .Builder(context, session) + .Builder(appContext, session) .setConnectionHints(bundle) .buildAsync() @@ -88,7 +90,7 @@ object PlaybackServiceClient { { try { val controller = controllerFuture.get() - mediaControllerState.controller.value = controller + mediaControllerState.controller = controller // checks if the player is still active before engaging further if (mediaControllerState.isActive()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index e34dc096c..78398f156 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager -import com.vitorpamplona.amethyst.service.playback.composable.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING +import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index 5e7583c6f..d79dd754c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -55,25 +55,25 @@ object MediaSaverToDisk { onSuccess: () -> Any?, onError: (Throwable) -> Any?, ) { - videoUri?.let { theVideoUri -> - if (!theVideoUri.startsWith("file")) { - downloadAndSave( - url = theVideoUri, - mimeType = mimeType, - context = localContext, - forceProxy = forceProxy, - onSuccess = onSuccess, - onError = onError, - ) - } else { + when { + videoUri.isNullOrBlank() -> return + videoUri.startsWith("file") -> save( - localFile = theVideoUri.toUri().toFile(), + localFile = videoUri.toUri().toFile(), mimeType = mimeType, context = localContext, onSuccess = onSuccess, onError = onError, ) - } + else -> + downloadAndSave( + url = videoUri, + mimeType = mimeType, + forceProxy = forceProxy, + context = localContext, + onSuccess = onSuccess, + onError = onError, + ) } } @@ -120,8 +120,8 @@ object MediaSaverToDisk { check(response.isSuccessful) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentType = response.header("Content-Type") - checkNotNull(contentType) { "Can't find out the content type" } + val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url)) + check(contentType.isNotBlank()) { "Can't find out the content type" } val realType = if (contentType == "application/octet-stream") { @@ -240,11 +240,9 @@ object MediaSaverToDisk { File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), PICTURES_SUBDIRECTORY, - ) - - if (!subdirectory.exists()) { - subdirectory.mkdirs() - } + ).apply { + if (!exists()) mkdirs() + } val outputFile = File(subdirectory, fileName) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt index 15ea0e454..339ce9dcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt @@ -24,10 +24,10 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.view.Window +import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider -import com.vitorpamplona.amethyst.ui.navigation.getActivity // Window utils @Composable @@ -46,9 +46,9 @@ private tailrec fun Context.getActivityWindow(): Window? = @Composable fun getActivity(): Activity? = LocalView.current.context.getActivity() -private tailrec fun Context.getActivity(): Activity? = +tailrec fun Context.getActivity(): ComponentActivity = when (this) { - is Activity -> this + is ComponentActivity -> this is ContextWrapper -> baseContext.getActivity() - else -> null + else -> throw IllegalStateException("Requires a ComponentActivity to run") } 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 ff4b2c795..13ecd28de 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 @@ -441,6 +441,7 @@ private fun RenderImageOrVideo( VideoViewInner( videoUri = content.url, mimeType = content.mimeType, + aspectRatio = ratio, title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, @@ -499,6 +500,7 @@ private fun RenderImageOrVideo( VideoViewInner( videoUri = it.toUri().toString(), mimeType = content.mimeType, + aspectRatio = ratio, title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, 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 eb216dc59..356b056fd 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 @@ -84,13 +84,11 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils -import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon import com.vitorpamplona.amethyst.ui.note.HashCheckIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size24dp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b8c6abcb6..bc76d11a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation -import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -48,10 +46,10 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages +import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel @@ -521,11 +519,6 @@ private fun NavigateIfIntentRequested( } } -fun Context.getActivity(): MainActivity { - if (this is MainActivity) return this - return if (this is ContextWrapper) baseContext.getActivity() else getActivity() -} - private fun isSameRoute( currentRoute: String?, newRoute: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 7aaf2127a..b9120f386 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos +import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Clear @@ -45,6 +46,7 @@ import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -453,26 +455,13 @@ fun PinIcon( } @Composable -fun LyricsIcon( +fun EnablePiP( modifier: Modifier, tint: Color, ) { Icon( - painter = painterResource(id = R.drawable.lyrics_on), - contentDescription = stringRes(id = R.string.accessibility_lyrics_on), - modifier = modifier, - tint = tint, - ) -} - -@Composable -fun LyricsOffIcon( - modifier: Modifier, - tint: Color, -) { - Icon( - painter = painterResource(id = R.drawable.lyrics_off), - contentDescription = stringRes(id = R.string.accessibility_lyrics_off), + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = stringRes(id = R.string.enter_picture_in_picture), modifier = modifier, tint = tint, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 2f6fcd176..dcc68181f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -151,8 +151,8 @@ import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.Nav -import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.CancelIcon import com.vitorpamplona.amethyst.ui.note.CloseIcon diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 32ecb90e1..76737f6c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -284,19 +284,26 @@ fun UrlVideoView( DownloadForOfflineIcon(Size75dp, Color.White) } } else { - GetMediaItem(content.url, content.description, content.artworkUri, content.authorName, content.uri) { mediaItem -> + GetMediaItem( + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + callbackUri = content.uri, + mimeType = content.mimeType, + aspectRatio = ratio, + proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)), + ) { mediaItem -> GetVideoController( mediaItem = mediaItem, - videoUri = content.url, muted = true, - proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)), ) { controller -> AndroidView( modifier = Modifier, factory = { context: Context -> PlayerView(context).apply { clipToOutline = true - player = controller.controller.value + player = controller.controller setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) controllerAutoShow = false @@ -306,7 +313,7 @@ fun UrlVideoView( resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL - controller.controller.value?.playWhenReady = true + controller.controller?.playWhenReady = true } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt index 40819be48..5bb86f7e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle @@ -32,6 +33,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrThreadDataSource +import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel @@ -85,6 +87,13 @@ fun ThreadScreen( onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } + LoadNote(noteId, accountViewModel) { + if (it != null) { + // this will force loading every post from this thread. + val metadata = it.live().metadata.observeAsState() + } + } + DisappearingScaffold( isInvertedLayout = false, topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index cbdc8f383..1399a91e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -94,7 +94,6 @@ import com.vitorpamplona.amethyst.service.PackageUtils import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 7267d82bf..336a81b44 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -863,6 +863,7 @@ Souhrn změn Rychlé opravy… Přijmout návrhy + Spustit video ve vyskakovacím okně Stáhnout Text písně zapnuto Text písně vypnuto diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index a24e5ee86..7914be591 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -868,6 +868,7 @@ anz der Bedingungen ist erforderlich Zusammenfassung der Änderungen Schnelle Korrekturen… Den Vorschlag annehmen + Video in einem Pop-Up starten Herunterladen Liedtext an Liedtext aus diff --git a/amethyst/src/main/res/values-hu/strings.xml b/amethyst/src/main/res/values-hu/strings.xml index 103dc3ff2..afec08f2f 100644 --- a/amethyst/src/main/res/values-hu/strings.xml +++ b/amethyst/src/main/res/values-hu/strings.xml @@ -20,6 +20,7 @@ Profilutánzás Illegális viselkedés Egyéb + Zaklatás Ismeretlen Átjátszóikon Ismeretlen szerző diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index daa6f5f5d..5fab57d0c 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -20,6 +20,7 @@ Impersonatie Illegaal gedrag Overige + Pesterijen Onbekend Relay-icoon Onbekende auteur @@ -81,6 +82,13 @@ Hartelijk bedankt! Bedrag in sats Verstuur sats + Geheime Emoji-maker + Voeg een emoji toe met verborgen bericht + Geheime notitie voor ontvanger + Mijn verborgen bericht + Zichtbaar voorvoegsel + 😎 + Voeg toe aan bericht "Error parsing preview voor %1$s : %2$s" "Voorbeeld kaartafbeelding voor %1$s" Nieuw kanaal @@ -106,6 +114,7 @@ Globale feed Zoeken Voeg relay toe + Naam Naam Mijn naam Struisvogel McAwesome @@ -141,6 +150,7 @@ Discussies Notes Reacties + Jouw Galerij "Volgend" "Rapporten" diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 01f4d5b25..b8d043a8d 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -863,6 +863,7 @@ Resumo das alterações Correções rápidas… Aceitar a Sugestão + Iniciar o vídeo em um popup Baixar Letras ligadas Letras desligadas diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index faad5aef4..40eef6e3c 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -862,6 +862,7 @@ Sammanfattning av ändringar Snabba fixar… Acceptera förslaget + Starta video i ett popup-fönster Ladda ner Undertexter på Undertexter av diff --git a/amethyst/src/main/res/values/colors.xml b/amethyst/src/main/res/values/colors.xml index f8c6127d3..2525a0df9 100644 --- a/amethyst/src/main/res/values/colors.xml +++ b/amethyst/src/main/res/values/colors.xml @@ -7,4 +7,5 @@ #FF018786 #FF000000 #FFFFFFFF + #00FFFFFF \ No newline at end of file diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e65894dbf..5f73c53be 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1044,6 +1044,8 @@ Accept the Suggestion + Start video in a popup + Download Lyrics on Lyrics off diff --git a/amethyst/src/main/res/values/themes.xml b/amethyst/src/main/res/values/themes.xml index a654cef2b..0935ce7a7 100644 --- a/amethyst/src/main/res/values/themes.xml +++ b/amethyst/src/main/res/values/themes.xml @@ -4,4 +4,7 @@ @color/purple_700 shortEdges + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index efeb84b50..11b4ace86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanistAdaptive = "0.37.2" activityCompose = "1.10.1" -agp = "8.9.0" +agp = "8.9.1" android-compileSdk = "35" android-minSdk = "26" android-targetSdk = "35"