Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-03-25 10:53:49 +00:00
committed by GitHub
45 changed files with 743 additions and 299 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,9 +59,7 @@ 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()
}
} }
} }
@@ -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,11 +222,8 @@ 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) { PlaybackServiceClient.removeController(controllerId)
// Stops and releases the media.
PlaybackServiceClient.removeController(controllerId)
}
} }
} }
@@ -237,7 +232,7 @@ fun GetVideoController(
} }
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(
videoUri,
title,
artworkUri,
authorName,
nostrUriCallback,
mimeType,
aspectRatio,
proxyPort =
HttpClientManager.getCurrentProxyPort(
accountViewModel.account.shouldUseTorForVideoDownload(videoUri),
),
) { mediaItem ->
GetVideoController( GetVideoController(
mediaItem = mediaItem, mediaItem = mediaItem,
videoUri = videoUri,
muted = muted, muted = muted,
proxyPort =
HttpClientManager.getCurrentProxyPort(
accountViewModel.account.shouldUseTorForVideoDownload(videoUri),
),
) { 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
} }
KeepPlayingButton( val context = LocalContext.current.getActivity()
controllerState.keepPlaying,
PictureInPictureButton(
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(videoUri)) { if (!isLiveStreaming(mediaData.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,28 +27,31 @@ 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 =
MediaItem LoadedMediaItem(
.Builder() key,
.setMediaId(key.videoUri) MediaItem
.setUri(key.videoUri) .Builder()
.setMediaMetadata( .setMediaId(key.videoUri)
MediaMetadata .setUri(key.videoUri)
.Builder() .setMediaMetadata(
.setArtist(key.authorName?.ifBlank { null }) MediaMetadata
.setTitle(key.title?.ifBlank { null } ?: key.videoUri) .Builder()
.setExtras( .setArtist(key.authorName?.ifBlank { null })
Bundle().apply { .setTitle(key.title?.ifBlank { null } ?: key.videoUri)
putString("callbackUri", key.callbackUri) .setExtras(
}, Bundle().apply {
).setArtworkUri( putString("callbackUri", key.callbackUri)
try { },
key.artworkUri?.toUri() ).setArtworkUri(
} catch (e: Exception) { try {
if (e is CancellationException) throw e key.artworkUri?.toUri()
null } catch (e: Exception) {
}, if (e is CancellationException) throw e
).build(), null
).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,20 +18,37 @@
* 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 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
@@ -39,35 +56,18 @@ object BackgroundMedia {
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
}
},
)
}
}
@@ -165,6 +165,8 @@ class MediaSessionPool(
fun playingContent() = playingMap.values fun playingContent() = playingMap.values
fun getSession(id: String) = cache.get(id)?.session
class MediaSessionCallback( class MediaSessionCallback(
val pool: MediaSessionPool, val pool: MediaSessionPool,
) : MediaSession.Callback { ) : MediaSession.Callback {
@@ -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) {
@@ -29,6 +29,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager 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.ExoPlayerBuilder
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
@@ -91,37 +92,46 @@ class PlaybackService : MediaSessionService() {
// Updates any new player ready // Updates any new player ready
super.onUpdateNotification(session, startInForegroundRequired) 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 val playing = (poolWithProxy?.playingContent() ?: emptyList()) + (poolNoProxy?.playingContent() ?: emptyList())
proxyPlaying?.forEach {
if (it.session.player.isPlaying) { // 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) super.onUpdateNotification(it.session, startInForegroundRequired)
return
} }
} }
// Overrides again with playing with audio playing.forEachIndexed { idx, it ->
proxyPlaying?.forEach {
if (it.session.player.isPlaying && it.session.player.volume > 0) { if (it.session.player.isPlaying && it.session.player.volume > 0) {
super.onUpdateNotification(it.session, startInForegroundRequired) super.onUpdateNotification(it.session, startInForegroundRequired)
return
} }
} }
val noProxyPlaying = poolNoProxy?.playingContent() playing.forEachIndexed { idx, it ->
// Overrides the notification with any player actually playing
noProxyPlaying?.forEach {
if (it.session.player.isPlaying) { if (it.session.player.isPlaying) {
super.onUpdateNotification(it.session, startInForegroundRequired) 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 // Return a MediaSession to link with the MediaController that is making
@@ -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
@@ -55,25 +55,25 @@ object MediaSaverToDisk {
onSuccess: () -> Any?, onSuccess: () -> Any?,
onError: (Throwable) -> Any?, onError: (Throwable) -> Any?,
) { ) {
videoUri?.let { theVideoUri -> when {
if (!theVideoUri.startsWith("file")) { videoUri.isNullOrBlank() -> return
downloadAndSave( videoUri.startsWith("file") ->
url = theVideoUri,
mimeType = mimeType,
context = localContext,
forceProxy = forceProxy,
onSuccess = onSuccess,
onError = onError,
)
} else {
save( save(
localFile = theVideoUri.toUri().toFile(), localFile = videoUri.toUri().toFile(),
mimeType = mimeType, mimeType = mimeType,
context = localContext, context = localContext,
onSuccess = onSuccess, onSuccess = onSuccess,
onError = onError, 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) check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type") val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url))
checkNotNull(contentType) { "Can't find out the content type" } check(contentType.isNotBlank()) { "Can't find out the content type" }
val realType = val realType =
if (contentType == "application/octet-stream") { if (contentType == "application/octet-stream") {
@@ -240,11 +240,9 @@ object MediaSaverToDisk {
File( File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY, PICTURES_SUBDIRECTORY,
) ).apply {
if (!exists()) mkdirs()
if (!subdirectory.exists()) { }
subdirectory.mkdirs()
}
val outputFile = File(subdirectory, fileName) val outputFile = File(subdirectory, fileName)
@@ -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
@@ -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( 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
} }
}, },
) )
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -32,6 +33,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrThreadDataSource 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.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel
@@ -85,6 +87,13 @@ fun ThreadScreen(
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } 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( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { topBar = {
@@ -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
@@ -863,6 +863,7 @@
<string name="message_to_author">Souhrn změn</string> <string name="message_to_author">Souhrn změn</string>
<string name="message_to_author_placeholder">Rychlé opravy…</string> <string name="message_to_author_placeholder">Rychlé opravy…</string>
<string name="accept_the_suggestion">Přijmout návrhy</string> <string name="accept_the_suggestion">Přijmout návrhy</string>
<string name="enter_picture_in_picture">Spustit video ve vyskakovacím okně</string>
<string name="accessibility_download_for_offline">Stáhnout</string> <string name="accessibility_download_for_offline">Stáhnout</string>
<string name="accessibility_lyrics_on">Text písně zapnuto</string> <string name="accessibility_lyrics_on">Text písně zapnuto</string>
<string name="accessibility_lyrics_off">Text písně vypnuto</string> <string name="accessibility_lyrics_off">Text písně vypnuto</string>
@@ -868,6 +868,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="message_to_author">Zusammenfassung der Änderungen</string> <string name="message_to_author">Zusammenfassung der Änderungen</string>
<string name="message_to_author_placeholder">Schnelle Korrekturen…</string> <string name="message_to_author_placeholder">Schnelle Korrekturen…</string>
<string name="accept_the_suggestion">Den Vorschlag annehmen</string> <string name="accept_the_suggestion">Den Vorschlag annehmen</string>
<string name="enter_picture_in_picture">Video in einem Pop-Up starten</string>
<string name="accessibility_download_for_offline">Herunterladen</string> <string name="accessibility_download_for_offline">Herunterladen</string>
<string name="accessibility_lyrics_on">Liedtext an</string> <string name="accessibility_lyrics_on">Liedtext an</string>
<string name="accessibility_lyrics_off">Liedtext aus</string> <string name="accessibility_lyrics_off">Liedtext aus</string>
@@ -20,6 +20,7 @@
<string name="impersonation">Profilutánzás</string> <string name="impersonation">Profilutánzás</string>
<string name="illegal_behavior">Illegális viselkedés</string> <string name="illegal_behavior">Illegális viselkedés</string>
<string name="other">Egyéb</string> <string name="other">Egyéb</string>
<string name="harassment">Zaklatás</string>
<string name="unknown">Ismeretlen</string> <string name="unknown">Ismeretlen</string>
<string name="relay_icon">Átjátszóikon</string> <string name="relay_icon">Átjátszóikon</string>
<string name="unknown_author">Ismeretlen szerző</string> <string name="unknown_author">Ismeretlen szerző</string>
@@ -20,6 +20,7 @@
<string name="impersonation">Impersonatie</string> <string name="impersonation">Impersonatie</string>
<string name="illegal_behavior">Illegaal gedrag</string> <string name="illegal_behavior">Illegaal gedrag</string>
<string name="other">Overige</string> <string name="other">Overige</string>
<string name="harassment">Pesterijen</string>
<string name="unknown">Onbekend</string> <string name="unknown">Onbekend</string>
<string name="relay_icon">Relay-icoon</string> <string name="relay_icon">Relay-icoon</string>
<string name="unknown_author">Onbekende auteur</string> <string name="unknown_author">Onbekende auteur</string>
@@ -81,6 +82,13 @@
<string name="thank_you_so_much">Hartelijk bedankt!</string> <string name="thank_you_so_much">Hartelijk bedankt!</string>
<string name="amount_in_sats">Bedrag in sats</string> <string name="amount_in_sats">Bedrag in sats</string>
<string name="send_sats">Verstuur sats</string> <string name="send_sats">Verstuur sats</string>
<string name="secret_emoji_maker">Geheime Emoji-maker</string>
<string name="secret_emoji_maker_explainer">Voeg een emoji toe met verborgen bericht</string>
<string name="secret_note_to_receiver">Geheime notitie voor ontvanger</string>
<string name="secret_note_to_receiver_placeholder">Mijn verborgen bericht</string>
<string name="secret_visible_text">Zichtbaar voorvoegsel</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Voeg toe aan bericht</string>
<string name="error_parsing_preview_for">"Error parsing preview voor %1$s : %2$s"</string> <string name="error_parsing_preview_for">"Error parsing preview voor %1$s : %2$s"</string>
<string name="preview_card_image_for">"Voorbeeld kaartafbeelding voor %1$s"</string> <string name="preview_card_image_for">"Voorbeeld kaartafbeelding voor %1$s"</string>
<string name="new_channel">Nieuw kanaal</string> <string name="new_channel">Nieuw kanaal</string>
@@ -106,6 +114,7 @@
<string name="global_feed">Globale feed</string> <string name="global_feed">Globale feed</string>
<string name="search_feed">Zoeken</string> <string name="search_feed">Zoeken</string>
<string name="add_a_relay">Voeg relay toe</string> <string name="add_a_relay">Voeg relay toe</string>
<string name="profile_name">Naam</string>
<string name="display_name">Naam</string> <string name="display_name">Naam</string>
<string name="my_display_name">Mijn naam</string> <string name="my_display_name">Mijn naam</string>
<string name="my_awesome_name">Struisvogel McAwesome</string> <string name="my_awesome_name">Struisvogel McAwesome</string>
@@ -141,6 +150,7 @@
<string name="conversations">Discussies</string> <string name="conversations">Discussies</string>
<string name="notes">Notes</string> <string name="notes">Notes</string>
<string name="replies">Reacties</string> <string name="replies">Reacties</string>
<string name="mutual">Jouw</string>
<string name="gallery">Galerij</string> <string name="gallery">Galerij</string>
<string name="follows">"Volgend"</string> <string name="follows">"Volgend"</string>
<string name="reports">"Rapporten"</string> <string name="reports">"Rapporten"</string>
@@ -863,6 +863,7 @@
<string name="message_to_author">Resumo das alterações</string> <string name="message_to_author">Resumo das alterações</string>
<string name="message_to_author_placeholder">Correções rápidas…</string> <string name="message_to_author_placeholder">Correções rápidas…</string>
<string name="accept_the_suggestion">Aceitar a Sugestão</string> <string name="accept_the_suggestion">Aceitar a Sugestão</string>
<string name="enter_picture_in_picture">Iniciar o vídeo em um popup</string>
<string name="accessibility_download_for_offline">Baixar</string> <string name="accessibility_download_for_offline">Baixar</string>
<string name="accessibility_lyrics_on">Letras ligadas</string> <string name="accessibility_lyrics_on">Letras ligadas</string>
<string name="accessibility_lyrics_off">Letras desligadas</string> <string name="accessibility_lyrics_off">Letras desligadas</string>
@@ -862,6 +862,7 @@
<string name="message_to_author">Sammanfattning av ändringar</string> <string name="message_to_author">Sammanfattning av ändringar</string>
<string name="message_to_author_placeholder">Snabba fixar…</string> <string name="message_to_author_placeholder">Snabba fixar…</string>
<string name="accept_the_suggestion">Acceptera förslaget</string> <string name="accept_the_suggestion">Acceptera förslaget</string>
<string name="enter_picture_in_picture">Starta video i ett popup-fönster</string>
<string name="accessibility_download_for_offline">Ladda ner</string> <string name="accessibility_download_for_offline">Ladda ner</string>
<string name="accessibility_lyrics_on">Undertexter på</string> <string name="accessibility_lyrics_on">Undertexter på</string>
<string name="accessibility_lyrics_off">Undertexter av</string> <string name="accessibility_lyrics_off">Undertexter av</string>
+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>
+1 -1
View File
@@ -1,7 +1,7 @@
[versions] [versions]
accompanistAdaptive = "0.37.2" accompanistAdaptive = "0.37.2"
activityCompose = "1.10.1" activityCompose = "1.10.1"
agp = "8.9.0" agp = "8.9.1"
android-compileSdk = "35" android-compileSdk = "35"
android-minSdk = "26" android-minSdk = "26"
android-targetSdk = "35" android-targetSdk = "35"