mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
- Turn video controller creation into a flow, removing most of the little hacks to get the controller to work in the lifecycle
- Uses new Media3 content view frames - Redesigns video playback interface to remove unused buttons and modernize it. - Adds our own buttons and indicators for the video playback - Checks if PiP is supported and active before showing the button. - Improves click interactions with the image dialog - Fixes Surface release bugs - Creates new interface for the Picture in Picture view - Fixes muting and play buttons on the Picture in Picture
This commit is contained in:
@@ -76,7 +76,6 @@
|
||||
android:launchMode="singleInstance"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:taskAffinity=".service.playback.pip.PipVideoActivity"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
@@ -33,16 +32,16 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
@Composable
|
||||
fun ControlWhenPlayerIsActive(
|
||||
mediaControllerState: MediaControllerState,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
automaticallyStartPlayback: Boolean,
|
||||
isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
|
||||
) {
|
||||
val controller = mediaControllerState.controller ?: return
|
||||
val controller = mediaControllerState.controller
|
||||
|
||||
LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
|
||||
// active means being fully visible
|
||||
if (isClosestToTheCenterOfTheScreen.value) {
|
||||
// should auto start video from settings?
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
if (!automaticallyStartPlayback) {
|
||||
if (controller.isPlaying) {
|
||||
// if it is visible, it's playing but it wasn't supposed to start automatically.
|
||||
controller.pause()
|
||||
|
||||
@@ -21,225 +21,55 @@
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.Player
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
@Composable
|
||||
fun GetVideoController(
|
||||
mediaItem: LoadedMediaItem,
|
||||
muted: Boolean = false,
|
||||
play: Boolean = false,
|
||||
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controllerState by remember(mediaItem) {
|
||||
PlaybackServiceClient
|
||||
.controllerAsFlow(
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
proxyPort = mediaItem.src.proxyPort,
|
||||
keepPlaying = mediaItem.src.keepPlaying,
|
||||
context = context,
|
||||
).onEach { state ->
|
||||
Log.d("PlaybackService", "Controller instance: ${state.controller}")
|
||||
|
||||
val onlyOnePreparing = remember { AtomicBoolean() }
|
||||
|
||||
val controllerId = remember(mediaItem.src.videoUri) { MediaControllerState() }
|
||||
|
||||
controllerId.composed = true
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Prepares a VideoPlayer from the foreground service.
|
||||
//
|
||||
// TODO: Review this code because a new Disposable Effect can run
|
||||
// before the onDispose of the previous composable and the onDispose
|
||||
// sometimes affects the new variables, not the old ones.
|
||||
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(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
|
||||
PlaybackServiceClient.prepareController(
|
||||
mediaControllerState = controllerId,
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
proxyPort = mediaItem.src.proxyPort,
|
||||
keepPlaying = mediaItem.src.keepPlaying,
|
||||
context = context,
|
||||
) { controllerId ->
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
if (!controllerId.isPlaying()) {
|
||||
if (BackgroundMedia.isPlaying()) {
|
||||
// There is a video playing, start this one on mute.
|
||||
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?.volume = if (muted) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
controllerId.controller?.setMediaItem(mediaItem.item)
|
||||
controllerId.controller?.prepare()
|
||||
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
controllerId.readyToDisplay.value = true
|
||||
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
if (BackgroundMedia.isPlaying()) {
|
||||
// There is a video playing, start this one on mute.
|
||||
state.controller.volume = 0f
|
||||
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying")
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
state.controller.volume = if (muted) 0f else 1f
|
||||
Log.d("PlaybackService", "OnEach $muted")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// has been loaded. prepare to play. This happens when the background video switches screens.
|
||||
controllerId.controller?.let {
|
||||
scope.launch {
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
|
||||
Log.d("PlaybackService", "Preparing Existing Video ${mediaItem.src.videoUri} ")
|
||||
|
||||
if (it.isPlaying) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
it.volume = if (muted) 0f else 1f
|
||||
}
|
||||
|
||||
if (mediaItem.item != it.currentMediaItem) {
|
||||
it.setMediaItem(mediaItem.item)
|
||||
}
|
||||
|
||||
it.prepare()
|
||||
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
if (play) {
|
||||
state.controller.playWhenReady = true
|
||||
}
|
||||
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
}
|
||||
}
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
onDispose {
|
||||
controllerId.composed = false
|
||||
if (!controllerId.pictureInPictureActive.value) {
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User pauses and resumes the app. What to do with videos?
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(key1 = lifeCycleOwner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
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.needsController()) {
|
||||
if (!onlyOnePreparing.getAndSet(true)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
|
||||
PlaybackServiceClient.prepareController(
|
||||
mediaControllerState = controllerId,
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
proxyPort = mediaItem.src.proxyPort,
|
||||
keepPlaying = mediaItem.src.keepPlaying,
|
||||
context = context,
|
||||
) { controllerId ->
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
// checks again to make sure no other thread has created a controller.
|
||||
if (!controllerId.isPlaying()) {
|
||||
if (BackgroundMedia.isPlaying()) {
|
||||
// There is a video playing, start this one on mute.
|
||||
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?.volume = if (muted) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
controllerId.controller?.setMediaItem(mediaItem.item)
|
||||
controllerId.controller?.prepare()
|
||||
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
controllerId.readyToDisplay.value = true
|
||||
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
|
||||
// checks if the player is still active after requesting to load
|
||||
if (!controllerId.isActive()) {
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
controllerId.composed = false
|
||||
PlaybackServiceClient.removeController(controllerId)
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
if (controllerId.readyToDisplay.value && controllerId.active.value) {
|
||||
controllerId.controller?.let {
|
||||
inner(controllerId)
|
||||
}
|
||||
controllerState?.let {
|
||||
inner(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ fun LoadThumbAndThenVideoView(
|
||||
contentScale: ContentScale,
|
||||
nostrUriCallback: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
onDialog: (() -> Unit)? = null,
|
||||
) {
|
||||
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -21,10 +21,8 @@
|
||||
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
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.common.Player
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@@ -34,25 +32,25 @@ class MediaControllerState(
|
||||
// each composable has an ID.
|
||||
val id: String = Uuid.random().toString(),
|
||||
// This is filled after the controller returns from this class
|
||||
var controller: MediaController? = null,
|
||||
// this set's the stage to keep playing on the background or not when the user leaves the screen
|
||||
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
|
||||
var composed: Boolean = false,
|
||||
var controller: Player,
|
||||
// visibility onscreen
|
||||
val visibility: VisibilityData = VisibilityData(),
|
||||
) {
|
||||
fun isPlaying() = controller?.isPlaying == true
|
||||
fun isPlaying() = controller.isPlaying
|
||||
|
||||
fun currrentMedia() = controller?.currentMediaItem?.mediaId
|
||||
fun currrentMedia() = controller.currentMediaItem?.mediaId
|
||||
|
||||
fun isActive() = active.value == true
|
||||
fun toggleMute() {
|
||||
controller.volume = if (controller.volume == 0f) 1f else 0f
|
||||
}
|
||||
|
||||
fun needsController() = controller == null
|
||||
fun togglePlayPause() {
|
||||
if (controller.isPlaying) {
|
||||
controller.pause()
|
||||
} else {
|
||||
controller.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
|
||||
@@ -20,28 +20,43 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
|
||||
import androidx.media3.ui.compose.ContentFrame
|
||||
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
|
||||
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
|
||||
|
||||
private fun getVideoSizeDp(player: Player): Size? {
|
||||
var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat())
|
||||
|
||||
if (videoSize.width == 0f || videoSize.height == 0f) return null
|
||||
|
||||
val par = player.videoSize.pixelWidthHeightRatio
|
||||
if (par < 1.0) {
|
||||
videoSize = videoSize.copy(width = videoSize.width * par)
|
||||
} else if (par > 1.0) {
|
||||
videoSize = videoSize.copy(height = videoSize.height / par)
|
||||
}
|
||||
return videoSize
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
fun RenderVideoPlayer(
|
||||
@@ -52,74 +67,37 @@ fun RenderVideoPlayer(
|
||||
contentScale: ContentScale,
|
||||
borderModifier: Modifier,
|
||||
videoModifier: Modifier,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)?,
|
||||
onDialog: (() -> Unit)? = null,
|
||||
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = borderModifier) {
|
||||
AndroidView(
|
||||
modifier = videoModifier,
|
||||
factory = { context: Context ->
|
||||
PlayerView(context).apply {
|
||||
player = controllerState.controller
|
||||
// if we alrady know the size of the frame, this forces the player to stay in the size
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
|
||||
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
||||
|
||||
controllerAutoShow = false
|
||||
useController = showControls
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
|
||||
resizeMode =
|
||||
when (contentScale) {
|
||||
ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
|
||||
if (showControls) {
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
controllerState.controller?.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener { visible ->
|
||||
controllerVisible.value = visible == View.VISIBLE
|
||||
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
ContentFrame(
|
||||
player = controllerState.controller,
|
||||
modifier =
|
||||
videoModifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null, // to prevent the ripple from the tap
|
||||
) { controllerVisible.value = !controllerVisible.value },
|
||||
surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts.
|
||||
contentScale = contentScale,
|
||||
)
|
||||
|
||||
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
|
||||
|
||||
if (showControls) {
|
||||
RenderControlButtons(
|
||||
mediaItem.src,
|
||||
controllerState,
|
||||
controllerVisible,
|
||||
Modifier.align(Alignment.TopEnd),
|
||||
accountViewModel,
|
||||
RenderTopButtons(
|
||||
mediaData = mediaItem.src,
|
||||
controllerState = controllerState,
|
||||
controllerVisible = controllerVisible,
|
||||
onZoomClick = onDialog,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
controllerState.controller?.volume = 0f
|
||||
|
||||
RenderCenterButtons(controllerState, controllerVisible, Modifier.align(Alignment.Center))
|
||||
|
||||
RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,7 @@ fun VideoView(
|
||||
dimensions: DimensionTag? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
) {
|
||||
@@ -79,7 +78,7 @@ fun VideoView(
|
||||
Modifier
|
||||
}
|
||||
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -96,11 +95,10 @@ fun VideoView(
|
||||
dimensions: DimensionTag? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDialog: (() -> Unit)? = null,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
showControls: Boolean = true,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val automaticallyStartPlayback =
|
||||
remember {
|
||||
@@ -137,9 +135,8 @@ fun VideoView(
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback.value,
|
||||
onZoom = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
showControls = showControls,
|
||||
)
|
||||
@@ -184,9 +181,8 @@ fun VideoView(
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback.value,
|
||||
onZoom = onDialog,
|
||||
accountViewModel = accountViewModel,
|
||||
showControls = showControls,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -46,9 +46,9 @@ fun VideoViewInner(
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
automaticallyStartPlayback: Boolean,
|
||||
controllerVisible: MutableState<Boolean> = mutableStateOf(true),
|
||||
onZoom: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
||||
@@ -80,8 +80,8 @@ fun VideoViewInner(
|
||||
contentScale = contentScale,
|
||||
borderModifier = borderModifier,
|
||||
videoModifier = videoModifier,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog,
|
||||
controllerVisible = controllerVisible,
|
||||
onDialog = onZoom,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ZoomOutMap
|
||||
import androidx.compose.material3.Icon
|
||||
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 androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun FullScreenButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
FullScreenButton(
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
modifier = Modifier,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FullScreenButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
Box(modifier = PinBottomIconSize) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.7f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Size50Modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ZoomOutMap,
|
||||
contentDescription = stringRes(id = R.string.enter_picture_in_picture),
|
||||
modifier = modifier,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.state.rememberProgressStateWithTickCount
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HorizontalLinearProgressIndicatorPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Column(Modifier.background(BitcoinOrange)) {
|
||||
HorizontalLinearProgressIndicator(
|
||||
currentPositionProgress = { 0.00f },
|
||||
bufferedPositionProgress = { 0.10f },
|
||||
onLayoutWidthChanged = { widthPx -> },
|
||||
onSeek = {},
|
||||
)
|
||||
HorizontalLinearProgressIndicator(
|
||||
currentPositionProgress = { 0.10f },
|
||||
bufferedPositionProgress = { 0.20f },
|
||||
onLayoutWidthChanged = { widthPx -> },
|
||||
onSeek = {},
|
||||
)
|
||||
HorizontalLinearProgressIndicator(
|
||||
currentPositionProgress = { 1.00f },
|
||||
bufferedPositionProgress = { 1.00f },
|
||||
onLayoutWidthChanged = { widthPx -> },
|
||||
onSeek = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun HorizontalLinearProgressIndicator(
|
||||
player: Player,
|
||||
totalTickCount: Int = 0,
|
||||
) {
|
||||
var ticks by remember(totalTickCount) { mutableIntStateOf(totalTickCount) }
|
||||
val progressState = rememberProgressStateWithTickCount(player, ticks)
|
||||
HorizontalLinearProgressIndicator(
|
||||
currentPositionProgress = { progressState.currentPositionProgress },
|
||||
bufferedPositionProgress = { progressState.bufferedPositionProgress },
|
||||
onLayoutWidthChanged = { widthPx -> if (totalTickCount == 0) ticks = widthPx },
|
||||
onSeek = { pos -> player.seekTo((pos * player.duration).toLong()) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HorizontalLinearProgressIndicator(
|
||||
currentPositionProgress: () -> Float,
|
||||
bufferedPositionProgress: () -> Float = currentPositionProgress,
|
||||
onLayoutWidthChanged: (Int) -> Unit = {},
|
||||
onSeek: (Float) -> Unit,
|
||||
playedColor: Color = Color.White,
|
||||
bufferedColor: Color = Color.LightGray,
|
||||
unplayedColor: Color = Color.DarkGray,
|
||||
scrubberColor: Color = playedColor,
|
||||
rectHeightDp: Dp = 3.dp,
|
||||
) {
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = rectHeightDp * 2.5f)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
// Capture the exact position
|
||||
onSeek(offset.x / this.size.width.toFloat())
|
||||
}
|
||||
}.padding(vertical = rectHeightDp * 2)
|
||||
.height(rectHeightDp)
|
||||
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) },
|
||||
) {
|
||||
val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f)
|
||||
val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f)
|
||||
|
||||
drawRect(unplayedColor, size = Size(size.width, size.height))
|
||||
drawRect(bufferedColor, size = Size(bufferX, size.height))
|
||||
drawRect(playedColor, size = Size(positionX, size.height))
|
||||
|
||||
drawCircle(
|
||||
color = scrubberColor,
|
||||
radius = size.height * 1.5f,
|
||||
center = Offset(x = positionX, y = size.height / 2.0f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,20 +41,38 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.HorizontalLinearProgressIndicator
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MuteButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
MuteButton(
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
startingMuteState = true,
|
||||
modifier = Modifier,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MuteButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
modifier: Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
toggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val holdOn =
|
||||
@@ -83,7 +101,7 @@ fun MuteButton(
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.fillMaxSize(0.7f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||
|
||||
import android.app.AppOpsManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -31,23 +33,54 @@ 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 androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.ui.note.EnablePiP
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PictureInPictureButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
PictureInPictureButton(
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
modifier = Modifier,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PictureInPictureButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
modifier: Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isAllowed =
|
||||
remember {
|
||||
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||
val mode =
|
||||
appOps.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
|
||||
android.os.Process.myUid(),
|
||||
context.packageName,
|
||||
)
|
||||
mode == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
visible = isAllowed && controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
@@ -56,7 +89,7 @@ fun PictureInPictureButton(
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.fillMaxSize(0.7f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
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 androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PlayIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AnimatedPlayButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
AnimatedPlayPauseButton(
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
isPlaying = true,
|
||||
modifier = Modifier,
|
||||
) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AnimatedPauseButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
AnimatedPlayPauseButton(
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
isPlaying = false,
|
||||
modifier = Modifier,
|
||||
) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedPlayPauseButton(
|
||||
controllerVisible: State<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
isPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
PlayPauseButton(isPlaying, onClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(80.dp),
|
||||
) {
|
||||
if (!isPlaying) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
modifier = Size50Modifier,
|
||||
contentDescription = stringRes(R.string.play),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Pause,
|
||||
modifier = Size50Modifier,
|
||||
contentDescription = stringRes(R.string.play),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.state.rememberPlaybackSpeedState
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PlaybackSpeedPopUpButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
PlaybackSpeedPopUpButton(
|
||||
playbackSpeed = 0.50f,
|
||||
updatePlaybackSpeed = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BottomDialogOfChoicesBodyPreview() {
|
||||
ThemeComparisonRow {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
BottomDialogOfChoicesBody(
|
||||
currentSpeed = 0.50f,
|
||||
choices = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
|
||||
onDismissRequest = {},
|
||||
onSelectChoice = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackSpeedPopUpButton(
|
||||
player: Player,
|
||||
modifier: Modifier = Modifier,
|
||||
speedSelection: ImmutableList<Float> = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
|
||||
) {
|
||||
val state = rememberPlaybackSpeedState(player)
|
||||
|
||||
if (state.isEnabled) {
|
||||
PlaybackSpeedPopUpButton(
|
||||
state.playbackSpeed,
|
||||
state::updatePlaybackSpeed,
|
||||
modifier,
|
||||
speedSelection,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackSpeedPopUpButton(
|
||||
playbackSpeed: Float,
|
||||
updatePlaybackSpeed: (speed: Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
speedSelection: ImmutableList<Float> = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
|
||||
) {
|
||||
var openDialog by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Text(
|
||||
text = "%.1fx".format(playbackSpeed),
|
||||
modifier = modifier.clickable(onClick = { openDialog = true }),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
|
||||
if (openDialog) {
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
onDismissRequest = { openDialog = false },
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
BottomDialogOfChoicesBody(
|
||||
currentSpeed = playbackSpeed,
|
||||
choices = speedSelection,
|
||||
onDismissRequest = { openDialog = false },
|
||||
onSelectChoice = updatePlaybackSpeed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomDialogOfChoicesBody(
|
||||
currentSpeed: Float,
|
||||
choices: ImmutableList<Float>,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelectChoice: (Float) -> Unit,
|
||||
) {
|
||||
val colors =
|
||||
ButtonDefaults.textButtonColors().copy(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
choices.forEach { speed ->
|
||||
TextButton(
|
||||
colors = colors,
|
||||
onClick = {
|
||||
onSelectChoice(speed)
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
var fontWeight = FontWeight(400)
|
||||
if (speed == currentSpeed) {
|
||||
fontWeight = FontWeight(1000)
|
||||
}
|
||||
Text("%.1fx".format(speed), fontWeight = fontWeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun PositionAndDurationTextPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
PositionAndDurationText(
|
||||
133000,
|
||||
1000000,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PositionAndDurationText(
|
||||
player: Player,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state = rememberProgressStateWithTickInterval(player, tickIntervalMs = 1000L)
|
||||
|
||||
PositionAndDurationText(
|
||||
state.currentPositionMs,
|
||||
state.durationMs,
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun PositionAndDurationText(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val text = "${Util.getStringForTime(positionMs)} / ${Util.getStringForTime(durationMs)}"
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun RenderAnimatedBottomInfo(
|
||||
controllerState: MediaControllerState,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
// Button panel controls
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
HorizontalLinearProgressIndicator(controllerState.controller)
|
||||
RenderBottomButtonLine(controllerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun RenderBottomButtonLine(controllerState: MediaControllerState) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PositionAndDurationText(controllerState.controller)
|
||||
Spacer(Modifier.weight(1f))
|
||||
PlaybackSpeedPopUpButton(controllerState.controller)
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,35 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Rect
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
fun Activity.enterPipMode(
|
||||
ratio: Float?,
|
||||
bounds: Rect?,
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun RenderCenterButtons(
|
||||
controllerState: MediaControllerState,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
if (!isInPictureInPictureMode) {
|
||||
enterPictureInPictureMode(makePipParams(ratio, bounds))
|
||||
} else {
|
||||
setPictureInPictureParams(makePipParams(ratio, bounds))
|
||||
val state = rememberPlayPauseButtonState(controllerState.controller)
|
||||
|
||||
AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) {
|
||||
state.onClick()
|
||||
}
|
||||
|
||||
if (!state.showPlay) {
|
||||
LaunchedEffect(state.showPlay) {
|
||||
delay(2000)
|
||||
controllerVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
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.commons.richtext.MediaUrlVideo
|
||||
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.components.ShareMediaAction
|
||||
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 RenderControlButtons(
|
||||
mediaData: MediaItemData,
|
||||
controllerState: MediaControllerState,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
buttonPositionModifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
MuteButton(
|
||||
controllerVisible,
|
||||
(controllerState.controller?.volume ?: 0f) < 0.001,
|
||||
buttonPositionModifier,
|
||||
) { mute: Boolean ->
|
||||
// makes the new setting the default for new creations.
|
||||
DEFAULT_MUTED_SETTING.value = mute
|
||||
|
||||
controllerState.controller?.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
val context = LocalContext.current.getActivity()
|
||||
|
||||
PictureInPictureButton(
|
||||
controllerVisible,
|
||||
buttonPositionModifier.padding(end = Size55dp),
|
||||
) {
|
||||
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
|
||||
}
|
||||
|
||||
if (!isLiveStreaming(mediaData.videoUri)) {
|
||||
AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context ->
|
||||
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
||||
}
|
||||
|
||||
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle ->
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
|
||||
}
|
||||
} else {
|
||||
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle ->
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.composable.controls
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
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.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderTopButtonsPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
RenderTopButtons(
|
||||
mediaData = MediaItemData("http://test.mp4"),
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
startingMuteState = false,
|
||||
onMuteClick = { },
|
||||
onPictureInPictureClick = { },
|
||||
onZoomClick = { },
|
||||
modifier = Modifier,
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderTopButtons(
|
||||
mediaData: MediaItemData,
|
||||
controllerState: MediaControllerState,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
onZoomClick: (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current.getActivity()
|
||||
|
||||
RenderTopButtons(
|
||||
mediaData = mediaData,
|
||||
controllerVisible = controllerVisible,
|
||||
startingMuteState = controllerState.controller.volume < 0.001,
|
||||
onMuteClick = { mute ->
|
||||
// makes the new setting the default for new creations.
|
||||
DEFAULT_MUTED_SETTING.value = mute
|
||||
controllerState.controller.volume = if (mute) 0f else 1f
|
||||
},
|
||||
onPictureInPictureClick = {
|
||||
controllerState.controller.pause()
|
||||
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
|
||||
},
|
||||
onZoomClick =
|
||||
onZoomClick?.let {
|
||||
{
|
||||
controllerState.controller.pause()
|
||||
it()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderTopButtons(
|
||||
mediaData: MediaItemData,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
onMuteClick: (Boolean) -> Unit,
|
||||
onPictureInPictureClick: () -> Unit,
|
||||
onZoomClick: (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(modifier) {
|
||||
if (!isLiveStreaming(mediaData.videoUri)) {
|
||||
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
|
||||
ShareMediaAction(
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = mediaData.videoUri,
|
||||
postNostrUri = mediaData.callbackUri,
|
||||
blurhash = null,
|
||||
dim = null,
|
||||
hash = null,
|
||||
mimeType = mediaData.mimeType,
|
||||
onDismiss = toggle,
|
||||
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedSaveButton(controllerVisible) { context ->
|
||||
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
||||
}
|
||||
} else {
|
||||
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
|
||||
ShareMediaAction(
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = mediaData.videoUri,
|
||||
postNostrUri = mediaData.callbackUri,
|
||||
blurhash = null,
|
||||
dim = null,
|
||||
hash = null,
|
||||
mimeType = mediaData.mimeType,
|
||||
onDismiss = toggle,
|
||||
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onZoomClick != null) {
|
||||
FullScreenButton(
|
||||
controllerVisible = controllerVisible,
|
||||
onClick = onZoomClick,
|
||||
)
|
||||
}
|
||||
|
||||
PictureInPictureButton(
|
||||
controllerVisible = controllerVisible,
|
||||
onClick = onPictureInPictureClick,
|
||||
)
|
||||
|
||||
MuteButton(
|
||||
controllerVisible = controllerVisible,
|
||||
startingMuteState = startingMuteState,
|
||||
toggle = onMuteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,26 +38,43 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AnimatedSaveButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
AnimatedSaveButton(
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
modifier = Modifier,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedSaveButton(
|
||||
controllerVisible: State<Boolean>,
|
||||
modifier: Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
onSaveClick: (localContext: Context) -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
@@ -77,7 +94,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.fillMaxSize(0.7f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
@@ -40,16 +40,32 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AnimatedShareButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
AnimatedShareButton(
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
modifier = Modifier,
|
||||
) { _, _ -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedShareButton(
|
||||
controllerVisible: State<Boolean>,
|
||||
modifier: Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
@@ -64,13 +80,13 @@ fun AnimatedShareButton(
|
||||
|
||||
@Composable
|
||||
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
|
||||
Box(modifier = PinBottomIconSize) {
|
||||
Box(modifier = PinBottomIconSize, contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.7f)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -40,7 +39,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta
|
||||
import kotlin.math.abs
|
||||
|
||||
// This keeps the position of all visible videos in the current screen.
|
||||
val trackingVideos = mutableListOf<MediaControllerState>()
|
||||
val trackingVideos = mutableSetOf<MediaControllerState>()
|
||||
|
||||
/**
|
||||
* This function selects only one Video to be active. The video that is closest to the center of the
|
||||
@@ -52,17 +51,21 @@ fun VideoPlayerActiveMutex(
|
||||
inner: @Composable (Modifier, MutableState<Boolean>) -> Unit,
|
||||
) {
|
||||
// Is the current video the closest to the center?
|
||||
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(false) }
|
||||
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) }
|
||||
|
||||
// Keep track of all available videos.
|
||||
DisposableEffect(key1 = controller) {
|
||||
trackingVideos.add(controller)
|
||||
onDispose { trackingVideos.remove(controller) }
|
||||
onDispose {
|
||||
trackingVideos.remove(controller)
|
||||
}
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
|
||||
val videoModifier =
|
||||
remember(controller) {
|
||||
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { bounds, distanceToCenter ->
|
||||
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges(view) { bounds, distanceToCenter ->
|
||||
controller.visibility.bounds = bounds
|
||||
controller.visibility.distanceToCenter = distanceToCenter
|
||||
|
||||
@@ -93,15 +96,14 @@ fun VideoPlayerActiveMutex(
|
||||
inner(videoModifier, isClosestToTheCenterOfTheScreen)
|
||||
}
|
||||
|
||||
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier =
|
||||
composed {
|
||||
val view = LocalView.current
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
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 Modifier.onVisiblePositionChanges(
|
||||
view: View,
|
||||
onVisiblePosition: (Rect, Float?) -> Unit,
|
||||
): Modifier =
|
||||
onGloballyPositioned { coordinates ->
|
||||
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(
|
||||
|
||||
@@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import com.linc.audiowaveform.infiniteLinearGradient
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
@@ -56,31 +55,25 @@ fun Waveform(
|
||||
|
||||
val restartFlow = remember { mutableIntStateOf(0) }
|
||||
|
||||
val myController = mediaControllerState.controller
|
||||
|
||||
// Keeps the screen on while playing and viewing videos.
|
||||
if (myController != null) {
|
||||
DisposableEffect(key1 = myController) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
// doesn't consider the mutex because the screen can turn off if the video
|
||||
// being played in the mutex is not visible.
|
||||
if (isPlaying) {
|
||||
restartFlow.intValue += 1
|
||||
}
|
||||
DisposableEffect(key1 = mediaControllerState.controller) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
// doesn't consider the mutex because the screen can turn off if the video
|
||||
// being played in the mutex is not visible.
|
||||
if (isPlaying) {
|
||||
restartFlow.intValue += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
myController.addListener(listener)
|
||||
onDispose { myController.removeListener(listener) }
|
||||
}
|
||||
mediaControllerState.controller.addListener(listener)
|
||||
onDispose { mediaControllerState.controller.removeListener(listener) }
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = restartFlow.intValue) {
|
||||
mediaControllerState.controller?.let {
|
||||
pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
|
||||
}
|
||||
pollCurrentDuration(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -90,7 +83,7 @@ fun Waveform(
|
||||
}
|
||||
}
|
||||
|
||||
private fun pollCurrentDuration(controller: MediaController) =
|
||||
private fun pollCurrentDuration(controller: Player) =
|
||||
flow {
|
||||
while (controller.currentPosition <= controller.duration) {
|
||||
emit(controller.currentPosition / controller.duration.toFloat())
|
||||
|
||||
@@ -20,36 +20,43 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.pip
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object BackgroundMedia {
|
||||
// background playing mutex.
|
||||
val bgInstance = MutableStateFlow<MediaControllerState?>(null)
|
||||
var bgInstance: MediaControllerState? = null
|
||||
|
||||
fun hasInstance() = bgInstance.value != null
|
||||
fun hasInstance() = bgInstance != null
|
||||
|
||||
fun isPlaying() = bgInstance.value?.isPlaying() == true
|
||||
fun isPlaying() = bgInstance?.isPlaying() == true
|
||||
|
||||
fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance.value?.id
|
||||
fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance?.id
|
||||
|
||||
fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState)
|
||||
|
||||
fun removeBackgroundControllerAndReleaseIt() {
|
||||
bgInstance.value?.let {
|
||||
PlaybackServiceClient.removeController(it)
|
||||
bgInstance.tryEmit(null)
|
||||
}
|
||||
bgInstance = null
|
||||
}
|
||||
|
||||
fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
|
||||
bgInstance.tryEmit(mediaControllerState)
|
||||
bgInstance = mediaControllerState
|
||||
}
|
||||
|
||||
fun clearBackground(mediaControllerState: MediaControllerState) {
|
||||
if (bgInstance.value == mediaControllerState) {
|
||||
bgInstance.tryEmit(null)
|
||||
if (bgInstance == mediaControllerState) {
|
||||
bgInstance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegisterBackgroundMedia(controllerState: MediaControllerState) {
|
||||
DisposableEffect(controllerState) {
|
||||
BackgroundMedia.switchKeepPlaying(controllerState)
|
||||
onDispose {
|
||||
BackgroundMedia.clearBackground(controllerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.util.Consumer
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun rememberVideoDataFromIntents(): MutableState<MediaItemData?> {
|
||||
val activity = LocalContext.current.getActivity()
|
||||
|
||||
val videoData =
|
||||
remember {
|
||||
mutableStateOf(activity.processIntentForPiP(activity.intent))
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(activity) {
|
||||
val consumer =
|
||||
Consumer<Intent> { intent ->
|
||||
scope.launch {
|
||||
videoData.value = activity.processIntentForPiP(intent)
|
||||
}
|
||||
}
|
||||
|
||||
activity.addOnNewIntentListener(consumer)
|
||||
onDispose {
|
||||
activity.removeOnNewIntentListener(consumer)
|
||||
}
|
||||
}
|
||||
|
||||
return videoData
|
||||
}
|
||||
|
||||
fun Activity.processIntentForPiP(intent: Intent): MediaItemData? {
|
||||
val videoDataOnCreation = IntentExtras.loadBundle(intent.extras)
|
||||
val bounds = IntentExtras.loadBounds(intent.extras)
|
||||
val ratio = videoDataOnCreation?.aspectRatio ?: videoDataOnCreation?.videoUri?.let { MediaAspectRatioCache.get(it) }
|
||||
val isPlaying = true
|
||||
val isMuted = DEFAULT_MUTED_SETTING.value
|
||||
|
||||
if (!isInPictureInPictureMode) {
|
||||
enterPictureInPictureMode(makePipParams(isPlaying, isMuted, ratio, bounds))
|
||||
} else {
|
||||
setPictureInPictureParams(makePipParams(isPlaying, isMuted, ratio, bounds))
|
||||
}
|
||||
|
||||
return videoDataOnCreation
|
||||
}
|
||||
@@ -20,20 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.pip
|
||||
|
||||
import android.app.Activity
|
||||
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?,
|
||||
@@ -49,18 +40,25 @@ fun makePipParams(
|
||||
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
|
||||
fun Activity.makePipParams(
|
||||
isPlaying: Boolean,
|
||||
isMuted: Boolean,
|
||||
ratio: Float?,
|
||||
bounds: Rect?,
|
||||
): PictureInPictureParams =
|
||||
PictureInPictureParams
|
||||
.Builder()
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setAutoEnterEnabled(true)
|
||||
}
|
||||
|
||||
activity.addOnPictureInPictureModeChangedListener(observer)
|
||||
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
|
||||
}
|
||||
return pipMode
|
||||
}
|
||||
setActions(
|
||||
listOf(
|
||||
createPlayPauseAction(this@makePipParams, isPlaying),
|
||||
createMuteAction(this@makePipParams, isMuted),
|
||||
),
|
||||
)
|
||||
bounds?.let { setSourceRectHint(bounds) }
|
||||
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
|
||||
}.build()
|
||||
|
||||
@@ -23,15 +23,18 @@ 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.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
|
||||
class PipVideoActivity : ComponentActivity() {
|
||||
@@ -39,22 +42,22 @@ class PipVideoActivity : ComponentActivity() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
val videoData by rememberVideoDataFromIntents()
|
||||
videoData?.let { mediaItemData ->
|
||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
||||
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration,
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
GetMediaItem(mediaItemData) { mediaItem ->
|
||||
GetVideoController(mediaItem, muted, true) { controllerState ->
|
||||
RegisterBackgroundMedia(controllerState)
|
||||
RegisterControllerReceiver(controllerState)
|
||||
WatchControllerForActions(mediaItemData, controllerState)
|
||||
RenderPipVideo(controllerState, mediaItemData.waveformData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
@@ -67,11 +70,6 @@ class PipVideoActivity : ComponentActivity() {
|
||||
super.finish()
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun callIn(
|
||||
videoData: MediaItemData,
|
||||
@@ -86,13 +84,10 @@ class PipVideoActivity : ComponentActivity() {
|
||||
} else {
|
||||
ActivityOptions.makeBasic()
|
||||
}
|
||||
options.setLaunchBounds(videoBounds)
|
||||
|
||||
context.startActivity(
|
||||
Intent(context, PipVideoActivity::class.java).apply {
|
||||
Intent(context.applicationContext, PipVideoActivity::class.java).apply {
|
||||
putExtras(IntentExtras.createBundle(videoData, videoBounds))
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
},
|
||||
options.toBundle(),
|
||||
)
|
||||
|
||||
@@ -20,109 +20,58 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.pip
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.os.Build
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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 androidx.media3.ui.compose.ContentFrame
|
||||
import androidx.media3.ui.compose.state.rememberMuteButtonState
|
||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||
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.WaveformData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.makePipParams
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
|
||||
|
||||
@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, it.waveformData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PipVideo(
|
||||
fun RenderPipVideo(
|
||||
controller: MediaControllerState,
|
||||
waveformData: WaveformData?,
|
||||
) {
|
||||
DisposableEffect(controller) {
|
||||
BackgroundMedia.switchKeepPlaying(controller)
|
||||
onDispose {
|
||||
BackgroundMedia.clearBackground(controller)
|
||||
}
|
||||
}
|
||||
|
||||
val ratio =
|
||||
controller.currrentMedia()?.let {
|
||||
MediaAspectRatioCache.get(it)
|
||||
}
|
||||
|
||||
val modifier =
|
||||
if (ratio != null) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
} else {
|
||||
Modifier
|
||||
remember {
|
||||
val ratio =
|
||||
controller.currrentMedia()?.let {
|
||||
MediaAspectRatioCache.get(it)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
ContentFrame(
|
||||
player = controller.controller,
|
||||
keepContentOnReset = true,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -130,3 +79,51 @@ fun PipVideo(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegisterControllerReceiver(controllerState: MediaControllerState) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Use DisposableEffect to manage the receiver's lifecycle
|
||||
DisposableEffect(context) {
|
||||
val receiver =
|
||||
ActionReceiver(
|
||||
onMute = controllerState::toggleMute,
|
||||
onPlayPause = controllerState::togglePlayPause,
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(receiver, receiver.filterMute, RECEIVER_EXPORTED)
|
||||
context.registerReceiver(receiver, receiver.filterPlayPause, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(receiver, receiver.filterMute)
|
||||
context.registerReceiver(receiver, receiver.filterPlayPause)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun WatchControllerForActions(
|
||||
mediaItemData: MediaItemData,
|
||||
controllerState: MediaControllerState,
|
||||
) {
|
||||
val playPauseState = rememberPlayPauseButtonState(controllerState.controller)
|
||||
val muteState = rememberMuteButtonState(controllerState.controller)
|
||||
|
||||
val activity = LocalContext.current.getActivity()
|
||||
LaunchedEffect(activity, playPauseState.showPlay, muteState.showMuted) {
|
||||
activity.setPictureInPictureParams(
|
||||
activity.makePipParams(
|
||||
isPlaying = !playPauseState.showPlay,
|
||||
isMuted = !muteState.showMuted,
|
||||
ratio = mediaItemData.aspectRatio,
|
||||
bounds = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.PendingIntent
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
const val ACTION_MUTE = "com.vitorpamplona.amethyst.MUTE"
|
||||
const val ACTION_PLAY_PAUSE = "com.vitorpamplona.amethyst.PLAY_PAUSE"
|
||||
|
||||
class ActionReceiver(
|
||||
val onMute: () -> Unit,
|
||||
val onPlayPause: () -> Unit,
|
||||
) : BroadcastReceiver() {
|
||||
val filterMute = IntentFilter(ACTION_MUTE)
|
||||
val filterPlayPause = IntentFilter(ACTION_PLAY_PAUSE)
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
when (intent.action) {
|
||||
ACTION_MUTE -> onMute()
|
||||
ACTION_PLAY_PAUSE -> onPlayPause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createMuteAction(
|
||||
context: Context,
|
||||
isMuted: Boolean,
|
||||
): RemoteAction {
|
||||
val icon =
|
||||
if (isMuted) {
|
||||
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_up)
|
||||
} else {
|
||||
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_off)
|
||||
}
|
||||
val title = if (isMuted) stringRes(context, R.string.muted_button) else stringRes(context, R.string.mute_button)
|
||||
|
||||
val intent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(ACTION_MUTE),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return RemoteAction(icon, title, title, intent)
|
||||
}
|
||||
|
||||
fun createPlayPauseAction(
|
||||
context: Context,
|
||||
isPlaying: Boolean,
|
||||
): RemoteAction {
|
||||
val icon =
|
||||
if (!isPlaying) {
|
||||
Icon.createWithResource(context, androidx.media3.ui.compose.material3.R.drawable.media3_icon_play)
|
||||
} else {
|
||||
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_pause)
|
||||
}
|
||||
val title = if (!isPlaying) stringRes(context, R.string.play) else stringRes(context, R.string.pause)
|
||||
val intent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(ACTION_PLAY_PAUSE),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return RemoteAction(icon, title, title, intent)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class ExoPlayerPool(
|
||||
// Exists to avoid exceptions stopping the coroutine
|
||||
val exceptionHandler =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("BundledInsert", "Caught exception: ${throwable.message}", throwable)
|
||||
Log.e("PlaybackService", "Caught exception: ${throwable.message}", throwable)
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
|
||||
@@ -75,15 +75,19 @@ class ExoPlayerPool(
|
||||
|
||||
suspend fun releasePlayer(player: ExoPlayer) {
|
||||
mutex.withLock {
|
||||
player.pause()
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
if (playerPool.size < poolSize) {
|
||||
if (!playerPool.contains(player)) {
|
||||
playerPool.add(player)
|
||||
if (!player.isReleased) {
|
||||
player.pause()
|
||||
player.stop()
|
||||
player.clearVideoSurface()
|
||||
player.clearMediaItems()
|
||||
|
||||
if (playerPool.size < poolSize) {
|
||||
if (!playerPool.contains(player)) {
|
||||
playerPool.add(player)
|
||||
}
|
||||
} else {
|
||||
player.release() // Release if pool is full.
|
||||
}
|
||||
} else {
|
||||
player.release() // Release if pool is full.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import androidx.media3.session.MediaSession
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -99,10 +98,11 @@ class MediaSessionPool(
|
||||
.Builder(context, exoPlayerPool.acquirePlayer(context))
|
||||
.apply {
|
||||
setBitmapLoader(
|
||||
DataSourceBitmapLoader(
|
||||
DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(),
|
||||
OkHttpDataSource.Factory(okHttpClient),
|
||||
),
|
||||
DataSourceBitmapLoader
|
||||
.Builder(context)
|
||||
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
|
||||
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
.build(),
|
||||
)
|
||||
setId(id)
|
||||
setCallback(globalCallback)
|
||||
@@ -125,11 +125,9 @@ class MediaSessionPool(
|
||||
session.player.removeListener(listener.playerListener)
|
||||
}
|
||||
|
||||
cache.remove(session.id)
|
||||
playingMap.remove(session.id)
|
||||
cache.remove(session.id)
|
||||
session.release()
|
||||
exoPlayerPool.releasePlayerAsync(session.player as ExoPlayer)
|
||||
|
||||
cleanupUnused()
|
||||
}
|
||||
|
||||
@@ -144,7 +142,6 @@ class MediaSessionPool(
|
||||
// but not connected yet.
|
||||
// delay(10000)
|
||||
snap.values.forEach {
|
||||
Log.d("MediaSessionPoll", "Clean Up Unused ${it.session.connectedControllers.size} ${it.session.id}")
|
||||
if (it.session.connectedControllers.isEmpty()) {
|
||||
releaseSession(it.session)
|
||||
counter++
|
||||
|
||||
@@ -117,7 +117,7 @@ class PlaybackService : MediaSessionService() {
|
||||
|
||||
// if nothing is pl
|
||||
if (playing.isEmpty() && BackgroundMedia.hasInstance()) {
|
||||
BackgroundMedia.bgInstance.value?.id?.let { id ->
|
||||
BackgroundMedia.bgInstance?.id?.let { id ->
|
||||
(poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ class PlaybackService : MediaSessionService() {
|
||||
}
|
||||
|
||||
playing.forEachIndexed { idx, it ->
|
||||
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance.value?.id) {
|
||||
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
|
||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,91 +27,77 @@ import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object PlaybackServiceClient {
|
||||
val executorService: ExecutorService = Executors.newCachedThreadPool()
|
||||
|
||||
fun removeController(mediaControllerState: MediaControllerState) {
|
||||
mediaControllerState.active.value = false
|
||||
mediaControllerState.readyToDisplay.value = false
|
||||
|
||||
val myController = mediaControllerState.controller
|
||||
// release when can
|
||||
if (myController != null) {
|
||||
mediaControllerState.controller = null
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
// myController.pause()
|
||||
// myController.stop()
|
||||
myController.release()
|
||||
Log.d("PlaybackService", "Releasing Video $mediaControllerState")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareController(
|
||||
mediaControllerState: MediaControllerState,
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun controllerAsFlow(
|
||||
videoUri: String,
|
||||
proxyPort: Int? = 0,
|
||||
keepPlaying: Boolean = true,
|
||||
context: Context,
|
||||
onReady: (MediaControllerState) -> Unit,
|
||||
) {
|
||||
) = callbackFlow {
|
||||
val appContext = context.applicationContext
|
||||
val id = Uuid.random().toString()
|
||||
|
||||
mediaControllerState.active.value = true
|
||||
|
||||
try {
|
||||
val bundle =
|
||||
Bundle().apply {
|
||||
// link the id with the client's id to make sure it can return the
|
||||
// same session on background media.
|
||||
putString("id", mediaControllerState.id)
|
||||
putBoolean("keepPlaying", keepPlaying)
|
||||
proxyPort?.let {
|
||||
putInt("proxyPort", it)
|
||||
}
|
||||
val bundle =
|
||||
Bundle().apply {
|
||||
// link the id with the client's id to make sure it can return the
|
||||
// same session on background media.
|
||||
putString("id", id)
|
||||
putBoolean("keepPlaying", keepPlaying)
|
||||
proxyPort?.let {
|
||||
putInt("proxyPort", it)
|
||||
}
|
||||
}
|
||||
|
||||
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
|
||||
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
|
||||
|
||||
val controllerFuture =
|
||||
MediaController
|
||||
.Builder(appContext, session)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
val controllerFuture =
|
||||
MediaController
|
||||
.Builder(appContext, session)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
|
||||
Log.d("PlaybackService", "Preparing Controller ${mediaControllerState.id} $videoUri")
|
||||
Log.d("PlaybackService", "Preparing Controller $id $videoUri")
|
||||
|
||||
controllerFuture.addListener(
|
||||
{
|
||||
try {
|
||||
val controller = controllerFuture.get()
|
||||
mediaControllerState.controller = controller
|
||||
controllerFuture.addListener(
|
||||
{
|
||||
try {
|
||||
val controller = controllerFuture.get(5, TimeUnit.SECONDS)
|
||||
|
||||
// checks if the player is still active before engaging further
|
||||
if (mediaControllerState.isActive()) {
|
||||
onReady(mediaControllerState)
|
||||
} else {
|
||||
removeController(mediaControllerState)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
},
|
||||
executorService,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
Log.d("PlaybackService", "Controller Ready $id $videoUri")
|
||||
|
||||
// checks if the player is still active before engaging further
|
||||
trySend(
|
||||
MediaControllerState(
|
||||
id = id,
|
||||
controller = controller,
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $id $videoUri", e)
|
||||
close(e)
|
||||
}
|
||||
},
|
||||
executorService,
|
||||
)
|
||||
|
||||
awaitClose {
|
||||
Log.d("PlaybackService", "Releasing Controller $id $videoUri")
|
||||
try {
|
||||
MediaController.releaseFuture(controllerFuture)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Playback Client", "Failed to release Playback Client for $id $videoUri ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -125,9 +127,7 @@ fun ZoomableImageDialog(
|
||||
}
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize(), Alignment.TopCenter) {
|
||||
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
|
||||
}
|
||||
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,136 +158,143 @@ private fun DialogContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
) { index ->
|
||||
allImages.getOrNull(index)?.let {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
RenderImageOrVideo(
|
||||
content = it,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
controllerVisible = controllerVisible,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = {
|
||||
controllerVisible.value = !controllerVisible.value
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
onClick = {
|
||||
controllerVisible.value = !controllerVisible.value
|
||||
},
|
||||
),
|
||||
Alignment.TopCenter,
|
||||
) {
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
) { index ->
|
||||
allImages.getOrNull(index)?.let {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
RenderImageOrVideo(
|
||||
content = it,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
RenderImageOrVideo(
|
||||
content = imageUrl,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
controllerVisible = controllerVisible,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp)
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
RenderImageOrVideo(
|
||||
content = imageUrl,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = true,
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() },
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp)
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { popupExpanded.value = true },
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
|
||||
}
|
||||
|
||||
if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
|
||||
val localContext = LocalContext.current
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||
if (myContent is MediaUrlImage || myContent is MediaLocalImage) {
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
scope.launch {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { popupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
|
||||
val localContext = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
scope.launch {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.save_to_gallery),
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.save_to_gallery),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,11 +354,8 @@ private fun RenderImageOrVideo(
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val automaticallyStartPlayback = remember { mutableStateOf(true) }
|
||||
val contentScale =
|
||||
if (isFiniteHeight) {
|
||||
ContentScale.Fit
|
||||
@@ -368,9 +372,7 @@ private fun RenderImageOrVideo(
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
controllerVisible.value = !controllerVisible.value
|
||||
},
|
||||
)
|
||||
|
||||
@@ -412,8 +414,9 @@ private fun RenderImageOrVideo(
|
||||
authorName = content.authorName,
|
||||
borderModifier = borderModifier,
|
||||
contentScale = contentScale,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
nostrUriCallback = content.uri,
|
||||
automaticallyStartPlayback = true,
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -426,9 +429,7 @@ private fun RenderImageOrVideo(
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
controllerVisible.value = !controllerVisible.value
|
||||
},
|
||||
)
|
||||
|
||||
@@ -471,8 +472,9 @@ private fun RenderImageOrVideo(
|
||||
authorName = content.authorName,
|
||||
borderModifier = borderModifier,
|
||||
contentScale = contentScale,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
nostrUriCallback = content.uri,
|
||||
automaticallyStartPlayback = true,
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,9 +166,7 @@ fun ZoomableContentView(
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = {
|
||||
dialogOpen = true
|
||||
},
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -701,7 +699,6 @@ fun ShareMediaAction(
|
||||
) {
|
||||
if (content is MediaUrlContent) {
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = content.url,
|
||||
postNostrUri = content.uri,
|
||||
@@ -711,10 +708,10 @@ fun ShareMediaAction(
|
||||
mimeType = content.mimeType,
|
||||
onDismiss = onDismiss,
|
||||
content = content,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = content.localFile?.toUri().toString(),
|
||||
postNostrUri = content.uri,
|
||||
@@ -724,6 +721,7 @@ fun ShareMediaAction(
|
||||
mimeType = content.mimeType,
|
||||
onDismiss = onDismiss,
|
||||
content = content,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -731,7 +729,6 @@ fun ShareMediaAction(
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun ShareMediaAction(
|
||||
accountViewModel: AccountViewModel,
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
videoUri: String?,
|
||||
postNostrUri: String?,
|
||||
@@ -741,6 +738,7 @@ fun ShareMediaAction(
|
||||
mimeType: String?,
|
||||
onDismiss: () -> Unit,
|
||||
content: BaseMediaContent? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val scope = accountViewModel.viewModelScope
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.graphics.Rect
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -36,7 +36,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -44,23 +44,34 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.compose.ContentFrame
|
||||
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
|
||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedSaveButton
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedShareButton
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.MuteButton
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureInPictureButton
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -140,6 +151,7 @@ fun RenderAudioWithWaveform(
|
||||
|
||||
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(100.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GetMediaItem(
|
||||
@@ -189,35 +201,22 @@ fun RenderVoicePlayer(
|
||||
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = borderModifier) {
|
||||
AndroidView(
|
||||
ContentFrame(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
factory = { context: Context ->
|
||||
PlayerView(context).apply {
|
||||
player = controllerState.controller
|
||||
// if we already know the size of the frame, this forces the player to stay in the size
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
||||
|
||||
controllerAutoShow = false
|
||||
useController = true
|
||||
hideController()
|
||||
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener { visible ->
|
||||
controllerVisible.value = visible == View.VISIBLE
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
.height(100.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
|
||||
controllerState.visibility.bounds = boundRect
|
||||
}.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null, // to prevent the ripple from the tap
|
||||
) { controllerVisible.value = !controllerVisible.value },
|
||||
player = controllerState.controller,
|
||||
keepContentOnReset = true,
|
||||
surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts.
|
||||
)
|
||||
|
||||
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -225,71 +224,38 @@ fun RenderVoicePlayer(
|
||||
waveform?.let { Waveform(it, controllerState, Modifier) }
|
||||
}
|
||||
|
||||
RenderControlButtons(
|
||||
mediaItem.src,
|
||||
controllerState,
|
||||
controllerVisible,
|
||||
Modifier.align(Alignment.TopEnd),
|
||||
accountViewModel,
|
||||
RenderTopButtonsForVoice(
|
||||
mediaData = mediaItem.src,
|
||||
controllerState = controllerState,
|
||||
controllerVisible = controllerVisible,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlayPauseButton(controllerState: MediaControllerState) {
|
||||
val controller = controllerState.controller
|
||||
var isPlaying by remember(controller) {
|
||||
mutableStateOf(controllerState.isPlaying())
|
||||
}
|
||||
val state = rememberPlayPauseButtonState(controllerState.controller)
|
||||
|
||||
if (controller != null) {
|
||||
val view = LocalView.current
|
||||
// Keeps the screen on while playing and viewing videos.
|
||||
DisposableEffect(key1 = controller, key2 = view) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
// doesn't consider the mutex because the screen can turn off if the video
|
||||
// being played in the mutex is not visible.
|
||||
if (view.keepScreenOn != playing) {
|
||||
view.keepScreenOn = playing
|
||||
}
|
||||
isPlaying = playing
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
if (view.keepScreenOn) {
|
||||
view.keepScreenOn = false
|
||||
}
|
||||
isPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
controller.addListener(listener)
|
||||
onDispose {
|
||||
controller.removeListener(listener)
|
||||
listener.destroy()
|
||||
// Play/Pause Button
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (controllerState.controller.isPlaying) {
|
||||
controllerState.controller.pause()
|
||||
} else {
|
||||
controllerState.controller.play()
|
||||
}
|
||||
}
|
||||
|
||||
// Play/Pause Button
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (controller.isPlaying) {
|
||||
controller.pause()
|
||||
} else {
|
||||
controller.play()
|
||||
}
|
||||
},
|
||||
modifier = Size75Modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.PauseCircleOutline else Icons.Default.PlayCircleOutline,
|
||||
contentDescription = if (isPlaying) stringResource(R.string.pause) else stringResource(R.string.play),
|
||||
modifier = Size50Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Size75Modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.showPlay) Icons.Default.PlayCircleOutline else Icons.Default.PauseCircleOutline,
|
||||
contentDescription = if (state.showPlay) stringResource(R.string.play) else stringResource(R.string.pause),
|
||||
modifier = Size50Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,3 +309,90 @@ fun RenderAudioFromIMeta(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderTopButtonsForVoice(
|
||||
mediaData: MediaItemData,
|
||||
controllerState: MediaControllerState,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current.getActivity()
|
||||
|
||||
RenderTopButtonsForVoice(
|
||||
mediaData = mediaData,
|
||||
controllerVisible = controllerVisible,
|
||||
startingMuteState = controllerState.controller.volume < 0.001,
|
||||
onMuteClick = { mute ->
|
||||
// makes the new setting the default for new creations.
|
||||
DEFAULT_MUTED_SETTING.value = mute
|
||||
controllerState.controller.volume = if (mute) 0f else 1f
|
||||
},
|
||||
onPictureInPictureClick = {
|
||||
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
|
||||
},
|
||||
modifier = modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderTopButtonsForVoice(
|
||||
mediaData: MediaItemData,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
onMuteClick: (Boolean) -> Unit,
|
||||
onPictureInPictureClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(modifier) {
|
||||
if (!isLiveStreaming(mediaData.videoUri)) {
|
||||
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
|
||||
ShareMediaAction(
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = mediaData.videoUri,
|
||||
postNostrUri = mediaData.callbackUri,
|
||||
blurhash = null,
|
||||
dim = null,
|
||||
hash = null,
|
||||
mimeType = mediaData.mimeType,
|
||||
onDismiss = toggle,
|
||||
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedSaveButton(controllerVisible) { context ->
|
||||
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
||||
}
|
||||
} else {
|
||||
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
|
||||
ShareMediaAction(
|
||||
popupExpanded = popupExpanded,
|
||||
videoUri = mediaData.videoUri,
|
||||
postNostrUri = mediaData.callbackUri,
|
||||
blurhash = null,
|
||||
dim = null,
|
||||
hash = null,
|
||||
mimeType = mediaData.mimeType,
|
||||
onDismiss = toggle,
|
||||
content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PictureInPictureButton(
|
||||
controllerVisible = controllerVisible,
|
||||
onClick = onPictureInPictureClick,
|
||||
)
|
||||
|
||||
MuteButton(
|
||||
controllerVisible = controllerVisible,
|
||||
startingMuteState = startingMuteState,
|
||||
toggle = onMuteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +221,9 @@ val NotificationIconModifierSmaller = Modifier.width(55.dp).padding(end = 4.dp)
|
||||
val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp)
|
||||
val ChatHeadlineBorders = StdPadding
|
||||
|
||||
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
|
||||
val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp)
|
||||
val VolumeBottomIconSize = Modifier.size(60.dp).padding(5.dp)
|
||||
val PinBottomIconSize = Modifier.size(60.dp).padding(5.dp)
|
||||
val PlayIconSize = Modifier.size(110.dp).padding(10.dp)
|
||||
val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
|
||||
|
||||
val CashuCardBorders = Modifier.fillMaxWidth().padding(10.dp).clip(shape = QuoteBorder)
|
||||
@@ -375,6 +376,7 @@ val QuickActionPopupShadow = Modifier.shadow(elevation = Size6dp, shape = Smalle
|
||||
val SpacedBy2dp = Arrangement.spacedBy(Size2dp)
|
||||
val SpacedBy5dp = Arrangement.spacedBy(Size5dp)
|
||||
val SpacedBy10dp = Arrangement.spacedBy(Size10dp)
|
||||
val SpacedBy55dp = Arrangement.spacedBy(Size55dp)
|
||||
|
||||
val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user