Enable Picture in Picture videos

This commit is contained in:
Vitor Pamplona
2025-03-24 16:10:14 -04:00
parent 6fd5f36ff5
commit 8f9a4557b5
34 changed files with 670 additions and 260 deletions
+12
View File
@@ -69,6 +69,7 @@
android:launchMode="singleTop" android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout" android:configChanges="orientation|screenSize|screenLayout"
android:taskAffinity=".service.playback.pip.PipVideoActivity"
android:theme="@style/Theme.Amethyst"> android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst"> <intent-filter android:label="Amethyst">
@@ -121,6 +122,17 @@
android:screenOrientation="fullSensor" android:screenOrientation="fullSensor"
tools:replace="screenOrientation" /> tools:replace="screenOrientation" />
<activity
android:name=".service.playback.pip.PipVideoActivity"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode"
android:supportsPictureInPicture="true"
android:launchMode="singleTask"
android:exported="false"
android:resizeableActivity="true"
android:theme="@style/noAnimTheme"
/>
<service <service
android:name=".service.playback.service.PlaybackService" android:name=".service.playback.service.PlaybackService"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
@@ -27,6 +27,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.media3.common.Player import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@Composable @Composable
fun ControlWhenPlayerIsActive( fun ControlWhenPlayerIsActive(
@@ -34,7 +35,7 @@ fun ControlWhenPlayerIsActive(
automaticallyStartPlayback: State<Boolean>, automaticallyStartPlayback: State<Boolean>,
isClosestToTheCenterOfTheScreen: MutableState<Boolean>, isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
) { ) {
val controller = mediaControllerState.controller.value ?: return val controller = mediaControllerState.controller ?: return
LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) { LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
// active means being fully visible // active means being fully visible
@@ -58,11 +59,9 @@ fun ControlWhenPlayerIsActive(
// Pauses the video when it becomes invisible. // Pauses the video when it becomes invisible.
// Destroys the video later when it Disposes the element // Destroys the video later when it Disposes the element
// meanwhile if the user comes back, the position in the track is saved. // meanwhile if the user comes back, the position in the track is saved.
if (!mediaControllerState.keepPlaying.value) {
controller.pause() controller.pause()
} }
} }
}
val view = LocalView.current val view = LocalView.current
@@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.service.playback.composable
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.Player 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 com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -39,35 +39,33 @@ import java.util.concurrent.atomic.AtomicBoolean
@Composable @Composable
fun GetVideoController( fun GetVideoController(
mediaItem: State<MediaItem>, mediaItem: LoadedMediaItem,
videoUri: String,
proxyPort: Int?,
muted: Boolean = false, muted: Boolean = false,
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit, inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
) { ) {
val context = LocalContext.current 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() val scope = rememberCoroutineScope()
// Prepares a VideoPlayer from the foreground service. // 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 // If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player. // the notification of the video player.
if (controllerId.needsController()) { if (controllerId.needsController()) {
// If there is a connection, don't wait. // If there is a connection, don't wait.
if (!onlyOnePreparing.getAndSet(true)) { if (!onlyOnePreparing.getAndSet(true)) {
scope.launch { scope.launch {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} $videoUri") Log.d("PlaybackService", "Preparing Video ${controllerId.id} $mediaItem.src.videoUri")
PlaybackServiceClient.prepareController( PlaybackServiceClient.prepareController(
controllerId, controllerId,
videoUri, mediaItem.src.videoUri,
proxyPort, mediaItem.src.proxyPort,
context, context,
) { controllerId -> ) { controllerId ->
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
@@ -82,16 +80,16 @@ fun GetVideoController(
if (!controllerId.isPlaying()) { if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) { if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute. // There is a video playing, start this one on mute.
controllerId.controller.value?.volume = 0f controllerId.controller?.volume = 0f
} else { } else {
// There is no other video playing. Use the default mute state to // There is no other video playing. Use the default mute state to
// decide if sound is on or not. // 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?.setMediaItem(mediaItem.item)
controllerId.controller.value?.prepare() controllerId.controller?.prepare()
// checks if the player is still active after requesting to load // checks if the player is still active after requesting to load
if (!controllerId.isActive()) { if (!controllerId.isActive()) {
@@ -114,7 +112,7 @@ fun GetVideoController(
} }
} else { } else {
// has been loaded. prepare to play. This happens when the background video switches screens. // has been loaded. prepare to play. This happens when the background video switches screens.
controllerId.controller.value?.let { controllerId.controller?.let {
scope.launch { scope.launch {
// checks if the player is still active after requesting to load // checks if the player is still active after requesting to load
if (!controllerId.isActive()) { if (!controllerId.isActive()) {
@@ -123,7 +121,7 @@ fun GetVideoController(
} }
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { 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) { if (it.isPlaying) {
// There is a video playing, start this one on mute. // There is a video playing, start this one on mute.
@@ -134,8 +132,8 @@ fun GetVideoController(
it.volume = if (muted) 0f else 1f it.volume = if (muted) 0f else 1f
} }
if (mediaItem.value != it.currentMediaItem) { if (mediaItem.item != it.currentMediaItem) {
it.setMediaItem(mediaItem.value) it.setMediaItem(mediaItem.item)
} }
it.prepare() it.prepare()
@@ -151,8 +149,8 @@ fun GetVideoController(
} }
onDispose { onDispose {
controllerId.composed.value = false controllerId.composed = false
if (!controllerId.keepPlaying.value) { if (!controllerId.pictureInPictureActive.value) {
PlaybackServiceClient.removeController(controllerId) PlaybackServiceClient.removeController(controllerId)
} }
} }
@@ -164,17 +162,17 @@ fun GetVideoController(
val observer = val observer =
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { 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 null, restarts the controller with a new one
// if the controller is not null, just continue playing what the controller was playing // 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)) { if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.Main) { 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( PlaybackServiceClient.prepareController(
controllerId, controllerId,
videoUri, mediaItem.src.videoUri,
proxyPort, mediaItem.src.proxyPort,
context, context,
) { controllerId -> ) { controllerId ->
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
@@ -190,16 +188,16 @@ fun GetVideoController(
if (!controllerId.isPlaying()) { if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) { if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute. // There is a video playing, start this one on mute.
controllerId.controller.value?.volume = 0f controllerId.controller?.volume = 0f
} else { } else {
// There is no other video playing. Use the default mute state to // There is no other video playing. Use the default mute state to
// decide if sound is on or not. // 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?.setMediaItem(mediaItem.item)
controllerId.controller.value?.prepare() controllerId.controller?.prepare()
// checks if the player is still active after requesting to load // checks if the player is still active after requesting to load
if (!controllerId.isActive()) { if (!controllerId.isActive()) {
@@ -224,20 +222,17 @@ fun GetVideoController(
} }
} }
if (event == Lifecycle.Event.ON_PAUSE) { if (event == Lifecycle.Event.ON_PAUSE) {
controllerId.composed.value = false controllerId.composed = false
if (!controllerId.keepPlaying.value) {
// Stops and releases the media.
PlaybackServiceClient.removeController(controllerId) PlaybackServiceClient.removeController(controllerId)
} }
} }
}
lifeCycleOwner.lifecycle.addObserver(observer) lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
} }
if (controllerId.readyToDisplay.value && controllerId.active.value) { if (controllerId.readyToDisplay.value && controllerId.active.value) {
controllerId.controller.value?.let { controllerId.controller?.let {
inner(controllerId) inner(controllerId)
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.playback.composable package com.vitorpamplona.amethyst.service.playback.composable
import android.graphics.Rect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -31,21 +32,29 @@ class MediaControllerState(
// each composable has an ID. // each composable has an ID.
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
// This is filled after the controller returns from this class // This is filled after the controller returns from this class
val controller: MutableState<MediaController?> = 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 // this set's the stage to keep playing on the background or not when the user leaves the screen
val keepPlaying: MutableState<Boolean> = mutableStateOf(false), val pictureInPictureActive: MutableState<Boolean> = mutableStateOf(false),
// this will be false if the screen leaves before the controller connection comes back from the service. // this will be false if the screen leaves before the controller connection comes back from the service.
val active: MutableState<Boolean> = mutableStateOf(true), val active: MutableState<Boolean> = mutableStateOf(true),
// this will be set to own when the controller is ready // this will be set to own when the controller is ready
val readyToDisplay: MutableState<Boolean> = mutableStateOf(false), val readyToDisplay: MutableState<Boolean> = mutableStateOf(false),
// isCurrentlyBeingRendered // isCurrentlyBeingRendered
val composed: MutableState<Boolean> = 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 isActive() = active.value == true
fun needsController() = controller.value == null fun needsController() = controller == null
}
@Stable
class VisibilityData {
var bounds: Rect? = null
var distanceToCenter: Float? = null
} }
@@ -36,7 +36,8 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView 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.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
@@ -44,13 +45,11 @@ import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
@Composable @Composable
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun RenderVideoPlayer( fun RenderVideoPlayer(
videoUri: String, mediaItem: LoadedMediaItem,
mimeType: String?, controllerState: MediaControllerState,
controller: MediaControllerState,
thumbData: VideoThumb?, thumbData: VideoThumb?,
showControls: Boolean = true, showControls: Boolean = true,
contentScale: ContentScale, contentScale: ContentScale,
nostrUriCallback: String?,
waveform: WaveformTag? = null, waveform: WaveformTag? = null,
borderModifier: Modifier, borderModifier: Modifier,
videoModifier: Modifier, videoModifier: Modifier,
@@ -58,14 +57,14 @@ fun RenderVideoPlayer(
onDialog: ((Boolean) -> Unit)?, onDialog: ((Boolean) -> Unit)?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val controllerVisible = remember(controller) { mutableStateOf(false) } val controllerVisible = remember(controllerState) { mutableStateOf(false) }
Box(modifier = borderModifier) { Box(modifier = borderModifier) {
AndroidView( AndroidView(
modifier = videoModifier, modifier = videoModifier,
factory = { context: Context -> factory = { context: Context ->
PlayerView(context).apply { PlayerView(context).apply {
player = controller.controller.value player = controllerState.controller
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
setBackgroundColor(Color.Transparent.toArgb()) setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb()) setShutterBackgroundColor(Color.Transparent.toArgb())
@@ -88,7 +87,7 @@ fun RenderVideoPlayer(
if (showControls) { if (showControls) {
onDialog?.let { innerOnDialog -> onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener { setFullscreenButtonClickListener {
controller.controller.value?.pause() controllerState.controller?.pause()
innerOnDialog(it) 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) { if (showControls) {
RenderControls( RenderControlButtons(
videoUri, mediaItem.src,
mimeType, controllerState,
controller,
nostrUriCallback,
controllerVisible, controllerVisible,
Modifier.align(Alignment.TopEnd), Modifier.align(Alignment.TopEnd),
accountViewModel, accountViewModel,
) )
} else { } else {
controller.controller.value?.volume = 0f controllerState.controller?.volume = 0f
} }
} }
} }
@@ -123,6 +123,7 @@ fun VideoView(
VideoViewInner( VideoViewInner(
videoUri = videoUri, videoUri = videoUri,
mimeType = mimeType, mimeType = mimeType,
aspectRatio = ratio,
title = title, title = title,
thumb = thumb, thumb = thumb,
borderModifier = borderModifier, borderModifier = borderModifier,
@@ -169,6 +170,7 @@ fun VideoView(
VideoViewInner( VideoViewInner(
videoUri = videoUri, videoUri = videoUri,
mimeType = mimeType, mimeType = mimeType,
aspectRatio = ratio,
title = title, title = title,
thumb = thumb, thumb = thumb,
borderModifier = borderModifier, borderModifier = borderModifier,
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -39,6 +38,7 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
fun VideoViewInner( fun VideoViewInner(
videoUri: String, videoUri: String,
mimeType: String?, mimeType: String?,
aspectRatio: Float? = null,
title: String? = null, title: String? = null,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
showControls: Boolean = true, showControls: Boolean = true,
@@ -56,26 +56,31 @@ fun VideoViewInner(
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value } val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value }
GetMediaItem(videoUri, title, artworkUri, authorName, nostrUriCallback) { mediaItem -> GetMediaItem(
GetVideoController( videoUri,
mediaItem = mediaItem, title,
videoUri = videoUri, artworkUri,
muted = muted, authorName,
nostrUriCallback,
mimeType,
aspectRatio,
proxyPort = proxyPort =
HttpClientManager.getCurrentProxyPort( HttpClientManager.getCurrentProxyPort(
accountViewModel.account.shouldUseTorForVideoDownload(videoUri), accountViewModel.account.shouldUseTorForVideoDownload(videoUri),
), ),
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = muted,
) { controller -> ) { controller ->
VideoPlayerActiveMutex(controller.id) { videoModifier, isClosestToTheCenterOfTheScreen -> VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen ->
ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen) ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen)
RenderVideoPlayer( RenderVideoPlayer(
videoUri = videoUri, mediaItem = mediaItem,
mimeType = mimeType, controllerState = controller,
controller = controller,
thumbData = thumb, thumbData = thumb,
showControls = showControls, showControls = showControls,
contentScale = contentScale, contentScale = contentScale,
nostrUriCallback = nostrUriCallback,
waveform = waveform, waveform = waveform,
borderModifier = borderModifier, borderModifier = borderModifier,
videoModifier = videoModifier, videoModifier = videoModifier,
@@ -31,26 +31,21 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import com.vitorpamplona.amethyst.ui.note.LyricsIcon import com.vitorpamplona.amethyst.ui.note.EnablePiP
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
@Composable @Composable
fun KeepPlayingButton( fun PictureInPictureButton(
keepPlayingStart: MutableState<Boolean>,
controllerVisible: MutableState<Boolean>, controllerVisible: MutableState<Boolean>,
modifier: Modifier, modifier: Modifier,
toggle: (Boolean) -> Unit, onClick: () -> Unit,
) { ) {
val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
AnimatedVisibility( AnimatedVisibility(
visible = controllerVisible.value, visible = controllerVisible.value,
modifier = modifier, modifier = modifier,
@@ -67,17 +62,10 @@ fun KeepPlayingButton(
) )
IconButton( IconButton(
onClick = { onClick = onClick,
keepPlaying.value = !keepPlaying.value
toggle(keepPlaying.value)
},
modifier = Size50Modifier, modifier = Size50Modifier,
) { ) {
if (keepPlaying.value) { EnablePiP(Size22Modifier, MaterialTheme.colorScheme.onBackground)
LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
} else {
LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
}
} }
} }
} }
@@ -25,79 +25,65 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R 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.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState 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.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.components.ShareImageAction 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size110dp import com.vitorpamplona.amethyst.ui.theme.Size110dp
import com.vitorpamplona.amethyst.ui.theme.Size165dp import com.vitorpamplona.amethyst.ui.theme.Size165dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size55dp
@Composable @Composable
fun RenderControls( fun RenderControlButtons(
videoUri: String, mediaData: MediaItemData,
mimeType: String?,
controllerState: MediaControllerState, controllerState: MediaControllerState,
nostrUriCallback: String?,
controllerVisible: MutableState<Boolean>, controllerVisible: MutableState<Boolean>,
buttonPositionModifier: Modifier, buttonPositionModifier: Modifier,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
MuteButton( MuteButton(
controllerVisible, controllerVisible,
(controllerState.controller.value?.volume ?: 0f) < 0.001, (controllerState.controller?.volume ?: 0f) < 0.001,
buttonPositionModifier, buttonPositionModifier,
) { mute: Boolean -> ) { mute: Boolean ->
// makes the new setting the default for new creations. // makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute DEFAULT_MUTED_SETTING.value = mute
// if the user unmutes a video and it's not the current playing, switches to that one. controllerState.controller?.volume = if (mute) 0f else 1f
if (!mute && BackgroundMedia.hasBackgroundButNot(controllerState)) {
BackgroundMedia.removeBackgroundControllerIfNotComposed()
} }
controllerState.controller.value?.volume = if (mute) 0f else 1f val context = LocalContext.current.getActivity()
}
KeepPlayingButton( PictureInPictureButton(
controllerState.keepPlaying,
controllerVisible, controllerVisible,
buttonPositionModifier.padding(end = Size55dp), buttonPositionModifier.padding(end = Size55dp),
) { newKeepPlaying: Boolean -> ) {
// If something else is playing and the user marks this video to keep playing, stops the other PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
// one.
if (newKeepPlaying) {
BackgroundMedia.switchKeepPlaying(controllerState)
} else {
// if removed from background.
if (BackgroundMedia.isMutex(controllerState)) {
BackgroundMedia.removeBackgroundControllerIfNotComposed()
}
} }
controllerState.keepPlaying.value = newKeepPlaying if (!isLiveStreaming(mediaData.videoUri)) {
}
if (!isLiveStreaming(videoUri)) {
AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context -> 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 -> 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 { } else {
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle -> 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?, videoUri: String?,
mimeType: String?, mimeType: String?,
localContext: Context, localContext: Context,
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -37,15 +36,11 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import kotlin.math.abs import kotlin.math.abs
// This keeps the position of all visible videos in the current screen. // This keeps the position of all visible videos in the current screen.
val trackingVideos = mutableListOf<VisibilityData>() val trackingVideos = mutableListOf<MediaControllerState>()
@Stable
class VisibilityData {
var distanceToCenter: Float? = null
}
/** /**
* This function selects only one Video to be active. The video that is closest to the center of the * 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 @Composable
fun VideoPlayerActiveMutex( fun VideoPlayerActiveMutex(
controller: String, controller: MediaControllerState,
inner: @Composable (Modifier, MutableState<Boolean>) -> Unit, inner: @Composable (Modifier, MutableState<Boolean>) -> Unit,
) { ) {
val myCache = remember(controller) { VisibilityData() }
// Is the current video the closest to the center? // Is the current video the closest to the center?
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(false) } val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(false) }
// Keep track of all available videos. // Keep track of all available videos.
DisposableEffect(key1 = controller) { DisposableEffect(key1 = controller) {
trackingVideos.add(myCache) trackingVideos.add(controller)
onDispose { trackingVideos.remove(myCache) } onDispose { trackingVideos.remove(controller) }
} }
val videoModifier = val videoModifier =
remember(controller) { remember(controller) {
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { distanceToCenter -> Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { bounds, distanceToCenter ->
myCache.distanceToCenter = distanceToCenter controller.visibility.bounds = bounds
controller.visibility.distanceToCenter = distanceToCenter
if (distanceToCenter != null) { if (distanceToCenter != null) {
// finds out of the current video is the closest to the center. // finds out of the current video is the closest to the center.
var newActive = true var newActive = true
for (video in trackingVideos) { for (video in trackingVideos) {
val videoPos = video.distanceToCenter val videoPos = video.visibility.distanceToCenter
if (videoPos != null && videoPos < distanceToCenter) { if (videoPos != null && videoPos < distanceToCenter) {
newActive = false newActive = false
break break
@@ -99,16 +93,21 @@ fun VideoPlayerActiveMutex(
inner(videoModifier, isClosestToTheCenterOfTheScreen) inner(videoModifier, isClosestToTheCenterOfTheScreen)
} }
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier =
composed { composed {
val view = LocalView.current val view = LocalView.current
onGloballyPositioned { coordinates -> 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 if (!isAttached) return null
// Window relative bounds of our compose root view that are visible on the screen // Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = Rect() val globalRootRect = Rect()
@@ -117,8 +116,6 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
return null return null
} }
val bounds = boundsInWindow()
if (bounds.isEmpty) return null if (bounds.isEmpty) return null
// Make sure we are completely in bounds. // Make sure we are completely in bounds.
@@ -129,7 +126,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
bounds.bottom <= globalRootRect.bottom bounds.bottom <= globalRootRect.bottom
) { ) {
return abs( return abs(
((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2), ((bounds.top + bounds.bottom) / 2.0f) - ((globalRootRect.top + globalRootRect.bottom) / 2.0f),
) )
} }
@@ -21,11 +21,8 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.commons.compose.produceCachedState import com.vitorpamplona.amethyst.commons.compose.produceCachedState
val mediaItemCache = MediaItemCache() val mediaItemCache = MediaItemCache()
@@ -33,11 +30,14 @@ val mediaItemCache = MediaItemCache()
@Composable @Composable
fun GetMediaItem( fun GetMediaItem(
videoUri: String, videoUri: String,
title: String?, title: String? = null,
artworkUri: String?, artworkUri: String? = null,
authorName: String?, authorName: String? = null,
callbackUri: String?, callbackUri: String? = null,
inner: @Composable (State<MediaItem>) -> Unit, mimeType: String? = null,
aspectRatio: Float? = null,
proxyPort: Int? = null,
inner: @Composable (LoadedMediaItem) -> Unit,
) { ) {
val data = val data =
remember(videoUri) { remember(videoUri) {
@@ -47,6 +47,9 @@ fun GetMediaItem(
title = title, title = title,
artworkUri = artworkUri, artworkUri = artworkUri,
callbackUri = callbackUri, callbackUri = callbackUri,
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = proxyPort,
) )
} }
@@ -56,12 +59,11 @@ fun GetMediaItem(
@Composable @Composable
fun GetMediaItem( fun GetMediaItem(
data: MediaItemData, data: MediaItemData,
inner: @Composable (State<MediaItem>) -> Unit, inner: @Composable (LoadedMediaItem) -> Unit,
) { ) {
val mediaItem by produceCachedState(cache = mediaItemCache, key = data) val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem?.let { mediaItem?.let {
val myState = remember(data.videoUri) { mutableStateOf(it) } inner(it)
inner(myState)
} }
} }
@@ -27,8 +27,10 @@ import androidx.media3.common.MediaMetadata
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
class MediaItemCache : GenericBaseCache<MediaItemData, MediaItem>(20) { class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
override suspend fun compute(key: MediaItemData): MediaItem = override suspend fun compute(key: MediaItemData): LoadedMediaItem =
LoadedMediaItem(
key,
MediaItem MediaItem
.Builder() .Builder()
.setMediaId(key.videoUri) .setMediaId(key.videoUri)
@@ -50,5 +52,6 @@ class MediaItemCache : GenericBaseCache<MediaItemData, MediaItem>(20) {
null null
}, },
).build(), ).build(),
).build() ).build(),
)
} }
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
@Immutable @Immutable
data class MediaItemData( data class MediaItemData(
@@ -29,4 +30,13 @@ data class MediaItemData(
val title: String? = null, val title: String? = null,
val artworkUri: String? = null, val artworkUri: String? = null,
val callbackUri: 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,
) )
@@ -57,7 +57,7 @@ fun Waveform(
val restartFlow = remember { mutableIntStateOf(0) } val restartFlow = remember { mutableIntStateOf(0) }
// Keeps the screen on while playing and viewing videos. // Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = mediaControllerState.controller.value) { DisposableEffect(key1 = mediaControllerState.controller) {
val listener = val listener =
object : Player.Listener { object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
@@ -69,12 +69,12 @@ fun Waveform(
} }
} }
mediaControllerState.controller.value?.addListener(listener) mediaControllerState.controller?.addListener(listener)
onDispose { mediaControllerState.controller.value?.removeListener(listener) } onDispose { mediaControllerState.controller?.removeListener(listener) }
} }
LaunchedEffect(key1 = restartFlow.intValue) { LaunchedEffect(key1 = restartFlow.intValue) {
mediaControllerState.controller.value?.let { mediaControllerState.controller?.let {
pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value } pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
} }
} }
@@ -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))
}
}
@@ -18,56 +18,56 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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 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 { object BackgroundMedia {
// background playing mutex. // background playing mutex.
val bgInstance = MutableStateFlow<MediaControllerState?>(null) val bgInstance = MutableStateFlow<MediaControllerState?>(null)
private fun hasInstance() = bgInstance.value != 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 isPlaying() = bgInstance.value?.isPlaying() == true fun isPlaying() = bgInstance.value?.isPlaying() == true
fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance.value?.id fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance.value?.id
fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState) 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() { fun removeBackgroundControllerAndReleaseIt() {
bgInstance.value?.let { bgInstance.value?.let {
removeController(it) PlaybackServiceClient.removeController(it)
bgInstance.tryEmit(null) clearBackground()
}
}
fun removeBackgroundControllerIfNotComposed() {
bgInstance.value?.let {
if (!it.composed.value) {
removeController(it)
}
bgInstance.tryEmit(null)
} }
} }
fun switchKeepPlaying(mediaControllerState: MediaControllerState) { fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
if (hasInstance() && !isMutex(mediaControllerState)) {
removeBackgroundControllerIfNotComposed()
}
bgInstance.tryEmit(mediaControllerState) bgInstance.tryEmit(mediaControllerState)
} }
fun clearBackground() {
bgInstance.tryEmit(null)
}
} }
@@ -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) }
}
}
}
@@ -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<PictureInPictureModeChangedInfo> { info ->
pipMode = info.isInPictureInPictureMode
}
activity.addOnPictureInPictureModeChangedListener(
observer,
)
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
}
return pipMode
}
@@ -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(),
)
}
}
}
@@ -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> { 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
}
},
)
}
}
@@ -34,11 +34,7 @@ class AspectRatioCacher(
mediaItem: MediaItem?, mediaItem: MediaItem?,
reason: Int, reason: Int,
) { ) {
if (mediaItem == null) { currentUrl = mediaItem?.mediaId
currentUrl = null
} else {
currentUrl = mediaItem.mediaId
}
} }
override fun onVideoSizeChanged(videoSize: VideoSize) { override fun onVideoSizeChanged(videoSize: VideoSize) {
@@ -41,10 +41,10 @@ object PlaybackServiceClient {
mediaControllerState.active.value = false mediaControllerState.active.value = false
mediaControllerState.readyToDisplay.value = false mediaControllerState.readyToDisplay.value = false
val myController = mediaControllerState.controller.value val myController = mediaControllerState.controller
// release when can // release when can
if (myController != null) { if (myController != null) {
mediaControllerState.controller.value = null mediaControllerState.controller = null
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {
// myController.pause() // myController.pause()
// myController.stop() // myController.stop()
@@ -61,6 +61,8 @@ object PlaybackServiceClient {
context: Context, context: Context,
onReady: (MediaControllerState) -> Unit, onReady: (MediaControllerState) -> Unit,
) { ) {
val appContext = context.applicationContext
mediaControllerState.active.value = true mediaControllerState.active.value = true
try { 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 = val controllerFuture =
MediaController MediaController
.Builder(context, session) .Builder(appContext, session)
.setConnectionHints(bundle) .setConnectionHints(bundle)
.buildAsync() .buildAsync()
@@ -88,7 +90,7 @@ object PlaybackServiceClient {
{ {
try { try {
val controller = controllerFuture.get() val controller = controllerFuture.get()
mediaControllerState.controller.value = controller mediaControllerState.controller = controller
// checks if the player is still active before engaging further // checks if the player is still active before engaging further
if (mediaControllerState.isActive()) { if (mediaControllerState.isActive()) {
@@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager 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.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -24,10 +24,10 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.view.Window import android.view.Window
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import com.vitorpamplona.amethyst.ui.navigation.getActivity
// Window utils // Window utils
@Composable @Composable
@@ -46,9 +46,9 @@ private tailrec fun Context.getActivityWindow(): Window? =
@Composable @Composable
fun getActivity(): Activity? = LocalView.current.context.getActivity() fun getActivity(): Activity? = LocalView.current.context.getActivity()
private tailrec fun Context.getActivity(): Activity? = tailrec fun Context.getActivity(): ComponentActivity =
when (this) { when (this) {
is Activity -> this is ComponentActivity -> this
is ContextWrapper -> baseContext.getActivity() is ContextWrapper -> baseContext.getActivity()
else -> null else -> throw IllegalStateException("Requires a ComponentActivity to run")
} }
@@ -441,6 +441,7 @@ private fun RenderImageOrVideo(
VideoViewInner( VideoViewInner(
videoUri = content.url, videoUri = content.url,
mimeType = content.mimeType, mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
authorName = content.authorName, authorName = content.authorName,
@@ -499,6 +500,7 @@ private fun RenderImageOrVideo(
VideoViewInner( VideoViewInner(
videoUri = it.toUri().toString(), videoUri = it.toUri().toString(),
mimeType = content.mimeType, mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
authorName = content.authorName, authorName = content.authorName,
@@ -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.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils 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.BlankNote
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size24dp
@@ -20,8 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
@@ -48,10 +46,10 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView
import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages 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.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
@@ -512,11 +510,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( private fun isSameRoute(
currentRoute: String?, currentRoute: String?,
newRoute: String, newRoute: String,
@@ -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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos 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.Bolt
import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Clear 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.filled.Share
import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -453,26 +455,13 @@ fun PinIcon(
} }
@Composable @Composable
fun LyricsIcon( fun EnablePiP(
modifier: Modifier, modifier: Modifier,
tint: Color, tint: Color,
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.lyrics_on), imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = stringRes(id = R.string.accessibility_lyrics_on), contentDescription = stringRes(id = R.string.enter_picture_in_picture),
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),
modifier = modifier, modifier = modifier,
tint = tint, tint = tint,
) )
@@ -151,8 +151,8 @@ import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.components.SecretEmojiRequest
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest 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.Nav
import com.vitorpamplona.amethyst.ui.navigation.getActivity
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon import com.vitorpamplona.amethyst.ui.note.CloseIcon
@@ -284,19 +284,26 @@ fun UrlVideoView(
DownloadForOfflineIcon(Size75dp, Color.White) DownloadForOfflineIcon(Size75dp, Color.White)
} }
} else { } 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( GetVideoController(
mediaItem = mediaItem, mediaItem = mediaItem,
videoUri = content.url,
muted = true, muted = true,
proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)),
) { controller -> ) { controller ->
AndroidView( AndroidView(
modifier = Modifier, modifier = Modifier,
factory = { context: Context -> factory = { context: Context ->
PlayerView(context).apply { PlayerView(context).apply {
clipToOutline = true clipToOutline = true
player = controller.controller.value player = controller.controller
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
controllerAutoShow = false controllerAutoShow = false
@@ -306,7 +313,7 @@ fun UrlVideoView(
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
controller.controller.value?.playWhenReady = true controller.controller?.playWhenReady = true
} }
}, },
) )
@@ -94,7 +94,6 @@ import com.vitorpamplona.amethyst.service.PackageUtils
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.getActivity 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.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
+1
View File
@@ -7,4 +7,5 @@
<color name="teal_700">#FF018786</color> <color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="transparent">#00FFFFFF</color>
</resources> </resources>
+2
View File
@@ -1044,6 +1044,8 @@
<string name="accept_the_suggestion">Accept the Suggestion</string> <string name="accept_the_suggestion">Accept the Suggestion</string>
<string name="enter_picture_in_picture">Start video in a popup</string>
<string name="accessibility_download_for_offline">Download</string> <string name="accessibility_download_for_offline">Download</string>
<string name="accessibility_lyrics_on">Lyrics on</string> <string name="accessibility_lyrics_on">Lyrics on</string>
<string name="accessibility_lyrics_off">Lyrics off</string> <string name="accessibility_lyrics_off">Lyrics off</string>
+3
View File
@@ -4,4 +4,7 @@
<item name="android:statusBarColor">@color/purple_700</item> <item name="android:statusBarColor">@color/purple_700</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
</style> </style>
<style name="noAnimTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources> </resources>