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:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:taskAffinity=".service.playback.pip.PipVideoActivity"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -121,6 +122,17 @@
android:screenOrientation="fullSensor"
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
android:name=".service.playback.service.PlaybackService"
android:foregroundServiceType="mediaPlayback"
@@ -27,6 +27,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.ui.platform.LocalView
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@Composable
fun ControlWhenPlayerIsActive(
@@ -34,7 +35,7 @@ fun ControlWhenPlayerIsActive(
automaticallyStartPlayback: State<Boolean>,
isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
) {
val controller = mediaControllerState.controller.value ?: return
val controller = mediaControllerState.controller ?: return
LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
// active means being fully visible
@@ -58,9 +59,7 @@ fun ControlWhenPlayerIsActive(
// Pauses the video when it becomes invisible.
// Destroys the video later when it Disposes the element
// meanwhile if the user comes back, the position in the track is saved.
if (!mediaControllerState.keepPlaying.value) {
controller.pause()
}
controller.pause()
}
}
@@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.service.playback.composable
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -39,35 +39,33 @@ import java.util.concurrent.atomic.AtomicBoolean
@Composable
fun GetVideoController(
mediaItem: State<MediaItem>,
videoUri: String,
proxyPort: Int?,
mediaItem: LoadedMediaItem,
muted: Boolean = false,
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
) {
val context = LocalContext.current
val onlyOnePreparing = AtomicBoolean()
val onlyOnePreparing = remember { AtomicBoolean() }
val controllerId = remember(videoUri) { BackgroundMedia.backgroundOrNewController(videoUri) }
val controllerId = remember(mediaItem.src.videoUri) { MediaControllerState() }
controllerId.composed.value = true
controllerId.composed = true
val scope = rememberCoroutineScope()
// Prepares a VideoPlayer from the foreground service.
DisposableEffect(key1 = videoUri) {
DisposableEffect(key1 = mediaItem.src.videoUri) {
// If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player.
if (controllerId.needsController()) {
// If there is a connection, don't wait.
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} $videoUri")
Log.d("PlaybackService", "Preparing Video ${controllerId.id} $mediaItem.src.videoUri")
PlaybackServiceClient.prepareController(
controllerId,
videoUri,
proxyPort,
mediaItem.src.videoUri,
mediaItem.src.proxyPort,
context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
@@ -82,16 +80,16 @@ fun GetVideoController(
if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
controllerId.controller.value?.volume = 0f
controllerId.controller?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controllerId.controller.value?.volume = if (muted) 0f else 1f
controllerId.controller?.volume = if (muted) 0f else 1f
}
}
controllerId.controller.value?.setMediaItem(mediaItem.value)
controllerId.controller.value?.prepare()
controllerId.controller?.setMediaItem(mediaItem.item)
controllerId.controller?.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
@@ -114,7 +112,7 @@ fun GetVideoController(
}
} else {
// has been loaded. prepare to play. This happens when the background video switches screens.
controllerId.controller.value?.let {
controllerId.controller?.let {
scope.launch {
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
@@ -123,7 +121,7 @@ fun GetVideoController(
}
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
Log.d("PlaybackService", "Preparing Existing Video $videoUri ")
Log.d("PlaybackService", "Preparing Existing Video ${mediaItem.src.videoUri} ")
if (it.isPlaying) {
// There is a video playing, start this one on mute.
@@ -134,8 +132,8 @@ fun GetVideoController(
it.volume = if (muted) 0f else 1f
}
if (mediaItem.value != it.currentMediaItem) {
it.setMediaItem(mediaItem.value)
if (mediaItem.item != it.currentMediaItem) {
it.setMediaItem(mediaItem.item)
}
it.prepare()
@@ -151,8 +149,8 @@ fun GetVideoController(
}
onDispose {
controllerId.composed.value = false
if (!controllerId.keepPlaying.value) {
controllerId.composed = false
if (!controllerId.pictureInPictureActive.value) {
PlaybackServiceClient.removeController(controllerId)
}
}
@@ -164,17 +162,17 @@ fun GetVideoController(
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
controllerId.composed.value = true
controllerId.composed = true
// if the controller is null, restarts the controller with a new one
// if the controller is not null, just continue playing what the controller was playing
if (controllerId.controller.value == null) {
if (controllerId.needsController()) {
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.Main) {
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} $videoUri ")
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
PlaybackServiceClient.prepareController(
controllerId,
videoUri,
proxyPort,
mediaItem.src.videoUri,
mediaItem.src.proxyPort,
context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
@@ -190,16 +188,16 @@ fun GetVideoController(
if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
controllerId.controller.value?.volume = 0f
controllerId.controller?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controllerId.controller.value?.volume = if (muted) 0f else 1f
controllerId.controller?.volume = if (muted) 0f else 1f
}
}
controllerId.controller.value?.setMediaItem(mediaItem.value)
controllerId.controller.value?.prepare()
controllerId.controller?.setMediaItem(mediaItem.item)
controllerId.controller?.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
@@ -224,11 +222,8 @@ fun GetVideoController(
}
}
if (event == Lifecycle.Event.ON_PAUSE) {
controllerId.composed.value = false
if (!controllerId.keepPlaying.value) {
// Stops and releases the media.
PlaybackServiceClient.removeController(controllerId)
}
controllerId.composed = false
PlaybackServiceClient.removeController(controllerId)
}
}
@@ -237,7 +232,7 @@ fun GetVideoController(
}
if (controllerId.readyToDisplay.value && controllerId.active.value) {
controllerId.controller.value?.let {
controllerId.controller?.let {
inner(controllerId)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable
import android.graphics.Rect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
@@ -31,21 +32,29 @@ class MediaControllerState(
// each composable has an ID.
val id: String = UUID.randomUUID().toString(),
// This is filled after the controller returns from this class
val controller: MutableState<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
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.
val active: MutableState<Boolean> = mutableStateOf(true),
// this will be set to own when the controller is ready
val readyToDisplay: MutableState<Boolean> = mutableStateOf(false),
// 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 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.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControls
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
@@ -44,13 +45,11 @@ import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
@Composable
@OptIn(UnstableApi::class)
fun RenderVideoPlayer(
videoUri: String,
mimeType: String?,
controller: MediaControllerState,
mediaItem: LoadedMediaItem,
controllerState: MediaControllerState,
thumbData: VideoThumb?,
showControls: Boolean = true,
contentScale: ContentScale,
nostrUriCallback: String?,
waveform: WaveformTag? = null,
borderModifier: Modifier,
videoModifier: Modifier,
@@ -58,14 +57,14 @@ fun RenderVideoPlayer(
onDialog: ((Boolean) -> Unit)?,
accountViewModel: AccountViewModel,
) {
val controllerVisible = remember(controller) { mutableStateOf(false) }
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
Box(modifier = borderModifier) {
AndroidView(
modifier = videoModifier,
factory = { context: Context ->
PlayerView(context).apply {
player = controller.controller.value
player = controllerState.controller
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
@@ -88,7 +87,7 @@ fun RenderVideoPlayer(
if (showControls) {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controller.controller.value?.pause()
controllerState.controller?.pause()
innerOnDialog(it)
}
}
@@ -103,20 +102,18 @@ fun RenderVideoPlayer(
},
)
waveform?.let { Waveform(it, controller, Modifier.align(Alignment.Center)) }
waveform?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
if (showControls) {
RenderControls(
videoUri,
mimeType,
controller,
nostrUriCallback,
RenderControlButtons(
mediaItem.src,
controllerState,
controllerVisible,
Modifier.align(Alignment.TopEnd),
accountViewModel,
)
} else {
controller.controller.value?.volume = 0f
controllerState.controller?.volume = 0f
}
}
}
@@ -123,6 +123,7 @@ fun VideoView(
VideoViewInner(
videoUri = videoUri,
mimeType = mimeType,
aspectRatio = ratio,
title = title,
thumb = thumb,
borderModifier = borderModifier,
@@ -169,6 +170,7 @@ fun VideoView(
VideoViewInner(
videoUri = videoUri,
mimeType = mimeType,
aspectRatio = ratio,
title = title,
thumb = thumb,
borderModifier = borderModifier,
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -39,6 +38,7 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
fun VideoViewInner(
videoUri: String,
mimeType: String?,
aspectRatio: Float? = null,
title: String? = null,
thumb: VideoThumb? = null,
showControls: Boolean = true,
@@ -56,26 +56,31 @@ fun VideoViewInner(
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value }
GetMediaItem(videoUri, title, artworkUri, authorName, nostrUriCallback) { mediaItem ->
GetMediaItem(
videoUri,
title,
artworkUri,
authorName,
nostrUriCallback,
mimeType,
aspectRatio,
proxyPort =
HttpClientManager.getCurrentProxyPort(
accountViewModel.account.shouldUseTorForVideoDownload(videoUri),
),
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
videoUri = videoUri,
muted = muted,
proxyPort =
HttpClientManager.getCurrentProxyPort(
accountViewModel.account.shouldUseTorForVideoDownload(videoUri),
),
) { controller ->
VideoPlayerActiveMutex(controller.id) { videoModifier, isClosestToTheCenterOfTheScreen ->
VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen ->
ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen)
RenderVideoPlayer(
videoUri = videoUri,
mimeType = mimeType,
controller = controller,
mediaItem = mediaItem,
controllerState = controller,
thumbData = thumb,
showControls = showControls,
contentScale = contentScale,
nostrUriCallback = nostrUriCallback,
waveform = waveform,
borderModifier = borderModifier,
videoModifier = videoModifier,
@@ -31,26 +31,21 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
import com.vitorpamplona.amethyst.ui.note.EnablePiP
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
@Composable
fun KeepPlayingButton(
keepPlayingStart: MutableState<Boolean>,
fun PictureInPictureButton(
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
toggle: (Boolean) -> Unit,
onClick: () -> Unit,
) {
val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
@@ -67,17 +62,10 @@ fun KeepPlayingButton(
)
IconButton(
onClick = {
keepPlaying.value = !keepPlaying.value
toggle(keepPlaying.value)
},
onClick = onClick,
modifier = Size50Modifier,
) {
if (keepPlaying.value) {
LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
} else {
LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
}
EnablePiP(Size22Modifier, MaterialTheme.colorScheme.onBackground)
}
}
}
@@ -25,79 +25,65 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.playback.composable.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.components.ShareImageAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size110dp
import com.vitorpamplona.amethyst.ui.theme.Size165dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@Composable
fun RenderControls(
videoUri: String,
mimeType: String?,
fun RenderControlButtons(
mediaData: MediaItemData,
controllerState: MediaControllerState,
nostrUriCallback: String?,
controllerVisible: MutableState<Boolean>,
buttonPositionModifier: Modifier,
accountViewModel: AccountViewModel,
) {
MuteButton(
controllerVisible,
(controllerState.controller.value?.volume ?: 0f) < 0.001,
(controllerState.controller?.volume ?: 0f) < 0.001,
buttonPositionModifier,
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
// if the user unmutes a video and it's not the current playing, switches to that one.
if (!mute && BackgroundMedia.hasBackgroundButNot(controllerState)) {
BackgroundMedia.removeBackgroundControllerIfNotComposed()
}
controllerState.controller.value?.volume = if (mute) 0f else 1f
controllerState.controller?.volume = if (mute) 0f else 1f
}
KeepPlayingButton(
controllerState.keepPlaying,
val context = LocalContext.current.getActivity()
PictureInPictureButton(
controllerVisible,
buttonPositionModifier.padding(end = Size55dp),
) { newKeepPlaying: Boolean ->
// If something else is playing and the user marks this video to keep playing, stops the other
// one.
if (newKeepPlaying) {
BackgroundMedia.switchKeepPlaying(controllerState)
} else {
// if removed from background.
if (BackgroundMedia.isMutex(controllerState)) {
BackgroundMedia.removeBackgroundControllerIfNotComposed()
}
}
controllerState.keepPlaying.value = newKeepPlaying
) {
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
}
if (!isLiveStreaming(videoUri)) {
if (!isLiveStreaming(mediaData.videoUri)) {
AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context ->
saveMediaToGallery(videoUri, mimeType, context, accountViewModel)
saveMediaToGalleryInner(mediaData.videoUri, mediaData.mimeType, context, accountViewModel)
}
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle)
}
} else {
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle)
}
}
}
private fun saveMediaToGallery(
private fun saveMediaToGalleryInner(
videoUri: String?,
mimeType: String?,
localContext: Context,
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -37,15 +36,11 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import kotlin.math.abs
// This keeps the position of all visible videos in the current screen.
val trackingVideos = mutableListOf<VisibilityData>()
@Stable
class VisibilityData {
var distanceToCenter: Float? = null
}
val trackingVideos = mutableListOf<MediaControllerState>()
/**
* This function selects only one Video to be active. The video that is closest to the center of the
@@ -53,30 +48,29 @@ class VisibilityData {
*/
@Composable
fun VideoPlayerActiveMutex(
controller: String,
controller: MediaControllerState,
inner: @Composable (Modifier, MutableState<Boolean>) -> Unit,
) {
val myCache = remember(controller) { VisibilityData() }
// Is the current video the closest to the center?
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(false) }
// Keep track of all available videos.
DisposableEffect(key1 = controller) {
trackingVideos.add(myCache)
onDispose { trackingVideos.remove(myCache) }
trackingVideos.add(controller)
onDispose { trackingVideos.remove(controller) }
}
val videoModifier =
remember(controller) {
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { distanceToCenter ->
myCache.distanceToCenter = distanceToCenter
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { bounds, distanceToCenter ->
controller.visibility.bounds = bounds
controller.visibility.distanceToCenter = distanceToCenter
if (distanceToCenter != null) {
// finds out of the current video is the closest to the center.
var newActive = true
for (video in trackingVideos) {
val videoPos = video.distanceToCenter
val videoPos = video.visibility.distanceToCenter
if (videoPos != null && videoPos < distanceToCenter) {
newActive = false
break
@@ -99,16 +93,21 @@ fun VideoPlayerActiveMutex(
inner(videoModifier, isClosestToTheCenterOfTheScreen)
}
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier =
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier =
composed {
val view = LocalView.current
onGloballyPositioned { coordinates ->
onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view))
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
}
}
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(
bounds: Rect,
view: View,
): Float? {
if (!isAttached) return null
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = Rect()
@@ -117,8 +116,6 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
return null
}
val bounds = boundsInWindow()
if (bounds.isEmpty) return null
// Make sure we are completely in bounds.
@@ -129,7 +126,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
bounds.bottom <= globalRootRect.bottom
) {
return abs(
((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2),
((bounds.top + bounds.bottom) / 2.0f) - ((globalRootRect.top + globalRootRect.bottom) / 2.0f),
)
}
@@ -21,11 +21,8 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
val mediaItemCache = MediaItemCache()
@@ -33,11 +30,14 @@ val mediaItemCache = MediaItemCache()
@Composable
fun GetMediaItem(
videoUri: String,
title: String?,
artworkUri: String?,
authorName: String?,
callbackUri: String?,
inner: @Composable (State<MediaItem>) -> Unit,
title: String? = null,
artworkUri: String? = null,
authorName: String? = null,
callbackUri: String? = null,
mimeType: String? = null,
aspectRatio: Float? = null,
proxyPort: Int? = null,
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val data =
remember(videoUri) {
@@ -47,6 +47,9 @@ fun GetMediaItem(
title = title,
artworkUri = artworkUri,
callbackUri = callbackUri,
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = proxyPort,
)
}
@@ -56,12 +59,11 @@ fun GetMediaItem(
@Composable
fun GetMediaItem(
data: MediaItemData,
inner: @Composable (State<MediaItem>) -> Unit,
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem?.let {
val myState = remember(data.videoUri) { mutableStateOf(it) }
inner(myState)
inner(it)
}
}
@@ -27,28 +27,31 @@ import androidx.media3.common.MediaMetadata
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import kotlin.coroutines.cancellation.CancellationException
class MediaItemCache : GenericBaseCache<MediaItemData, MediaItem>(20) {
override suspend fun compute(key: MediaItemData): MediaItem =
MediaItem
.Builder()
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
.setTitle(key.title?.ifBlank { null } ?: key.videoUri)
.setExtras(
Bundle().apply {
putString("callbackUri", key.callbackUri)
},
).setArtworkUri(
try {
key.artworkUri?.toUri()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
},
).build(),
).build()
class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
override suspend fun compute(key: MediaItemData): LoadedMediaItem =
LoadedMediaItem(
key,
MediaItem
.Builder()
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
.setTitle(key.title?.ifBlank { null } ?: key.videoUri)
.setExtras(
Bundle().apply {
putString("callbackUri", key.callbackUri)
},
).setArtworkUri(
try {
key.artworkUri?.toUri()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
},
).build(),
).build(),
)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
@Immutable
data class MediaItemData(
@@ -29,4 +30,13 @@ data class MediaItemData(
val title: String? = null,
val artworkUri: String? = null,
val callbackUri: String? = null,
val mimeType: String? = null,
val aspectRatio: Float? = null,
val proxyPort: Int? = null,
)
@Immutable
class LoadedMediaItem(
val src: MediaItemData,
val item: MediaItem,
)
@@ -57,7 +57,7 @@ fun Waveform(
val restartFlow = remember { mutableIntStateOf(0) }
// Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = mediaControllerState.controller.value) {
DisposableEffect(key1 = mediaControllerState.controller) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
@@ -69,12 +69,12 @@ fun Waveform(
}
}
mediaControllerState.controller.value?.addListener(listener)
onDispose { mediaControllerState.controller.value?.removeListener(listener) }
mediaControllerState.controller?.addListener(listener)
onDispose { mediaControllerState.controller?.removeListener(listener) }
}
LaunchedEffect(key1 = restartFlow.intValue) {
mediaControllerState.controller.value?.let {
mediaControllerState.controller?.let {
pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
}
}
@@ -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
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable
package com.vitorpamplona.amethyst.service.playback.pip
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient.removeController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
object BackgroundMedia {
// background playing mutex.
val bgInstance = MutableStateFlow<MediaControllerState?>(null)
private fun hasInstance() = bgInstance.value != null
private fun isComposed() = bgInstance.value?.composed?.value == true
private fun isUri(videoUri: String): Boolean = videoUri == bgInstance.value?.currrentMedia()
fun hasInstance() = bgInstance.value != null
fun isPlaying() = bgInstance.value?.isPlaying() == true
@@ -39,35 +56,18 @@ object BackgroundMedia {
fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState)
fun backgroundOrNewController(videoUri: String): MediaControllerState {
// allows only the first composable with the url of the video to match.
return if (isUri(videoUri) && !isComposed()) {
bgInstance.value ?: MediaControllerState()
} else {
MediaControllerState()
}
}
fun removeBackgroundControllerAndReleaseIt() {
bgInstance.value?.let {
removeController(it)
bgInstance.tryEmit(null)
}
}
fun removeBackgroundControllerIfNotComposed() {
bgInstance.value?.let {
if (!it.composed.value) {
removeController(it)
}
bgInstance.tryEmit(null)
PlaybackServiceClient.removeController(it)
clearBackground()
}
}
fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
if (hasInstance() && !isMutex(mediaControllerState)) {
removeBackgroundControllerIfNotComposed()
}
bgInstance.tryEmit(mediaControllerState)
}
fun clearBackground() {
bgInstance.tryEmit(null)
}
}
@@ -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 getSession(id: String) = cache.get(id)?.session
class MediaSessionCallback(
val pool: MediaSessionPool,
) : MediaSession.Callback {
@@ -34,11 +34,7 @@ class AspectRatioCacher(
mediaItem: MediaItem?,
reason: Int,
) {
if (mediaItem == null) {
currentUrl = null
} else {
currentUrl = mediaItem.mediaId
}
currentUrl = mediaItem?.mediaId
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
@@ -29,6 +29,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
@@ -91,37 +92,46 @@ class PlaybackService : MediaSessionService() {
// Updates any new player ready
super.onUpdateNotification(session, startInForegroundRequired)
val proxyPlaying = poolWithProxy?.playingContent()
// playback controllers control the last notification updated.
// this procedure re-updates the notification to make sure it aligns
// with users expectation on which playback they decide to control:
// 1. If no video is being played, play the picture in picture if there.
// 2. If there are videos being played the order is:
// 2. a. Picture in picture if playing
// 2. b. On screen video with volume on
// 2. c. On screen video with volume off.
// Overrides the notification with any player actually playing
proxyPlaying?.forEach {
if (it.session.player.isPlaying) {
val playing = (poolWithProxy?.playingContent() ?: emptyList()) + (poolNoProxy?.playingContent() ?: emptyList())
// if nothing is pl
if (playing.isEmpty() && BackgroundMedia.hasInstance()) {
BackgroundMedia.bgInstance.value?.id?.let { id ->
(poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
return
}
playing.forEachIndexed { idx, it ->
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance.value?.id) {
super.onUpdateNotification(it.session, startInForegroundRequired)
return
}
}
// Overrides again with playing with audio
proxyPlaying?.forEach {
playing.forEachIndexed { idx, it ->
if (it.session.player.isPlaying && it.session.player.volume > 0) {
super.onUpdateNotification(it.session, startInForegroundRequired)
return
}
}
val noProxyPlaying = poolNoProxy?.playingContent()
// Overrides the notification with any player actually playing
noProxyPlaying?.forEach {
playing.forEachIndexed { idx, it ->
if (it.session.player.isPlaying) {
super.onUpdateNotification(it.session, startInForegroundRequired)
}
}
// Overrides again with playing with audio
noProxyPlaying?.forEach {
if (it.session.player.isPlaying && it.session.player.volume > 0) {
super.onUpdateNotification(it.session, startInForegroundRequired)
}
}
}
// Return a MediaSession to link with the MediaController that is making
@@ -41,10 +41,10 @@ object PlaybackServiceClient {
mediaControllerState.active.value = false
mediaControllerState.readyToDisplay.value = false
val myController = mediaControllerState.controller.value
val myController = mediaControllerState.controller
// release when can
if (myController != null) {
mediaControllerState.controller.value = null
mediaControllerState.controller = null
GlobalScope.launch(Dispatchers.Main) {
// myController.pause()
// myController.stop()
@@ -61,6 +61,8 @@ object PlaybackServiceClient {
context: Context,
onReady: (MediaControllerState) -> Unit,
) {
val appContext = context.applicationContext
mediaControllerState.active.value = true
try {
@@ -74,11 +76,11 @@ object PlaybackServiceClient {
}
}
val session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
val controllerFuture =
MediaController
.Builder(context, session)
.Builder(appContext, session)
.setConnectionHints(bundle)
.buildAsync()
@@ -88,7 +90,7 @@ object PlaybackServiceClient {
{
try {
val controller = controllerFuture.get()
mediaControllerState.controller.value = controller
mediaControllerState.controller = controller
// checks if the player is still active before engaging further
if (mediaControllerState.isActive()) {
@@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.playback.composable.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -55,25 +55,25 @@ object MediaSaverToDisk {
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
videoUri?.let { theVideoUri ->
if (!theVideoUri.startsWith("file")) {
downloadAndSave(
url = theVideoUri,
mimeType = mimeType,
context = localContext,
forceProxy = forceProxy,
onSuccess = onSuccess,
onError = onError,
)
} else {
when {
videoUri.isNullOrBlank() -> return
videoUri.startsWith("file") ->
save(
localFile = theVideoUri.toUri().toFile(),
localFile = videoUri.toUri().toFile(),
mimeType = mimeType,
context = localContext,
onSuccess = onSuccess,
onError = onError,
)
}
else ->
downloadAndSave(
url = videoUri,
mimeType = mimeType,
forceProxy = forceProxy,
context = localContext,
onSuccess = onSuccess,
onError = onError,
)
}
}
@@ -120,8 +120,8 @@ object MediaSaverToDisk {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) { "Can't find out the content type" }
val contentType = response.header("Content-Type") ?: getMimeTypeFromExtension(trimInlineMetaData(url))
check(contentType.isNotBlank()) { "Can't find out the content type" }
val realType =
if (contentType == "application/octet-stream") {
@@ -240,11 +240,9 @@ object MediaSaverToDisk {
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY,
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
).apply {
if (!exists()) mkdirs()
}
val outputFile = File(subdirectory, fileName)
@@ -24,10 +24,10 @@ import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.Window
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import com.vitorpamplona.amethyst.ui.navigation.getActivity
// Window utils
@Composable
@@ -46,9 +46,9 @@ private tailrec fun Context.getActivityWindow(): Window? =
@Composable
fun getActivity(): Activity? = LocalView.current.context.getActivity()
private tailrec fun Context.getActivity(): Activity? =
tailrec fun Context.getActivity(): ComponentActivity =
when (this) {
is Activity -> this
is ComponentActivity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
else -> throw IllegalStateException("Requires a ComponentActivity to run")
}
@@ -441,6 +441,7 @@ private fun RenderImageOrVideo(
VideoViewInner(
videoUri = content.url,
mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
@@ -499,6 +500,7 @@ private fun RenderImageOrVideo(
VideoViewInner(
videoUri = it.toUri().toString(),
mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
@@ -84,13 +84,11 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils
import com.vitorpamplona.amethyst.ui.navigation.getActivity
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
@@ -48,10 +46,10 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView
import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
@@ -521,11 +519,6 @@ private fun NavigateIfIntentRequested(
}
}
fun Context.getActivity(): MainActivity {
if (this is MainActivity) return this
return if (this is ContextWrapper) baseContext.getActivity() else getActivity()
}
private fun isSameRoute(
currentRoute: String?,
newRoute: String,
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Clear
@@ -45,6 +46,7 @@ import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -453,26 +455,13 @@ fun PinIcon(
}
@Composable
fun LyricsIcon(
fun EnablePiP(
modifier: Modifier,
tint: Color,
) {
Icon(
painter = painterResource(id = R.drawable.lyrics_on),
contentDescription = stringRes(id = R.string.accessibility_lyrics_on),
modifier = modifier,
tint = tint,
)
}
@Composable
fun LyricsOffIcon(
modifier: Modifier,
tint: Color,
) {
Icon(
painter = painterResource(id = R.drawable.lyrics_off),
contentDescription = stringRes(id = R.string.accessibility_lyrics_off),
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = stringRes(id = R.string.enter_picture_in_picture),
modifier = modifier,
tint = tint,
)
@@ -151,8 +151,8 @@ import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SecretEmojiRequest
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.Nav
import com.vitorpamplona.amethyst.ui.navigation.getActivity
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon
@@ -284,19 +284,26 @@ fun UrlVideoView(
DownloadForOfflineIcon(Size75dp, Color.White)
}
} else {
GetMediaItem(content.url, content.description, content.artworkUri, content.authorName, content.uri) { mediaItem ->
GetMediaItem(
videoUri = content.url,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
callbackUri = content.uri,
mimeType = content.mimeType,
aspectRatio = ratio,
proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)),
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
videoUri = content.url,
muted = true,
proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)),
) { controller ->
AndroidView(
modifier = Modifier,
factory = { context: Context ->
PlayerView(context).apply {
clipToOutline = true
player = controller.controller.value
player = controller.controller
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
controllerAutoShow = false
@@ -306,7 +313,7 @@ fun UrlVideoView(
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
controller.controller.value?.playWhenReady = true
controller.controller?.playWhenReady = true
}
},
)
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
@@ -32,6 +33,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel
@@ -85,6 +87,13 @@ fun ThreadScreen(
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
LoadNote(noteId, accountViewModel) {
if (it != null) {
// this will force loading every post from this thread.
val metadata = it.live().metadata.observeAsState()
}
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
@@ -94,7 +94,6 @@ import com.vitorpamplona.amethyst.service.PackageUtils
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.getActivity
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
@@ -863,6 +863,7 @@
<string name="message_to_author">Souhrn změn</string>
<string name="message_to_author_placeholder">Rychlé opravy…</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_lyrics_on">Text písně zapnuto</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_placeholder">Schnelle Korrekturen…</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_lyrics_on">Liedtext an</string>
<string name="accessibility_lyrics_off">Liedtext aus</string>
@@ -20,6 +20,7 @@
<string name="impersonation">Profilutánzás</string>
<string name="illegal_behavior">Illegális viselkedés</string>
<string name="other">Egyéb</string>
<string name="harassment">Zaklatás</string>
<string name="unknown">Ismeretlen</string>
<string name="relay_icon">Átjátszóikon</string>
<string name="unknown_author">Ismeretlen szerző</string>
@@ -20,6 +20,7 @@
<string name="impersonation">Impersonatie</string>
<string name="illegal_behavior">Illegaal gedrag</string>
<string name="other">Overige</string>
<string name="harassment">Pesterijen</string>
<string name="unknown">Onbekend</string>
<string name="relay_icon">Relay-icoon</string>
<string name="unknown_author">Onbekende auteur</string>
@@ -81,6 +82,13 @@
<string name="thank_you_so_much">Hartelijk bedankt!</string>
<string name="amount_in_sats">Bedrag in 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="preview_card_image_for">"Voorbeeld kaartafbeelding voor %1$s"</string>
<string name="new_channel">Nieuw kanaal</string>
@@ -106,6 +114,7 @@
<string name="global_feed">Globale feed</string>
<string name="search_feed">Zoeken</string>
<string name="add_a_relay">Voeg relay toe</string>
<string name="profile_name">Naam</string>
<string name="display_name">Naam</string>
<string name="my_display_name">Mijn naam</string>
<string name="my_awesome_name">Struisvogel McAwesome</string>
@@ -141,6 +150,7 @@
<string name="conversations">Discussies</string>
<string name="notes">Notes</string>
<string name="replies">Reacties</string>
<string name="mutual">Jouw</string>
<string name="gallery">Galerij</string>
<string name="follows">"Volgend"</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_placeholder">Correções rápidas…</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_lyrics_on">Letras ligadas</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_placeholder">Snabba fixar…</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_lyrics_on">Undertexter på</string>
<string name="accessibility_lyrics_off">Undertexter av</string>
+1
View File
@@ -7,4 +7,5 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="transparent">#00FFFFFF</color>
</resources>
+2
View File
@@ -1044,6 +1044,8 @@
<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_lyrics_on">Lyrics on</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:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
</style>
<style name="noAnimTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>
+1 -1
View File
@@ -1,7 +1,7 @@
[versions]
accompanistAdaptive = "0.37.2"
activityCompose = "1.10.1"
agp = "8.9.0"
agp = "8.9.1"
android-compileSdk = "35"
android-minSdk = "26"
android-targetSdk = "35"