Merge branch 'vitorpamplona:main' into profiles-list-management
This commit is contained in:
@@ -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"
|
||||
|
||||
+3
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-37
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-6
@@ -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
|
||||
}
|
||||
|
||||
+12
-15
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+17
-12
@@ -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,
|
||||
|
||||
+5
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-32
@@ -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,
|
||||
+18
-21
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -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
-24
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
)
|
||||
|
||||
+4
-4
@@ -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))
|
||||
}
|
||||
}
|
||||
+30
-30
@@ -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)
|
||||
}
|
||||
}
|
||||
+84
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -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
|
||||
}
|
||||
+106
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -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 {
|
||||
|
||||
+1
-5
@@ -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) {
|
||||
|
||||
+27
-17
@@ -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
|
||||
|
||||
+7
-5
@@ -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")
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+12
-5
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+9
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user