- 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:launchMode="singleInstance"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:configChanges="orientation|screenSize|screenLayout"
|
android:configChanges="orientation|screenSize|screenLayout"
|
||||||
android:taskAffinity=".service.playback.pip.PipVideoActivity"
|
|
||||||
android:theme="@style/Theme.Amethyst">
|
android:theme="@style/Theme.Amethyst">
|
||||||
|
|
||||||
<intent-filter android:label="Amethyst">
|
<intent-filter android:label="Amethyst">
|
||||||
|
|||||||
+3
-4
@@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||||
@@ -33,16 +32,16 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
|||||||
@Composable
|
@Composable
|
||||||
fun ControlWhenPlayerIsActive(
|
fun ControlWhenPlayerIsActive(
|
||||||
mediaControllerState: MediaControllerState,
|
mediaControllerState: MediaControllerState,
|
||||||
automaticallyStartPlayback: State<Boolean>,
|
automaticallyStartPlayback: Boolean,
|
||||||
isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
|
isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
|
||||||
) {
|
) {
|
||||||
val controller = mediaControllerState.controller ?: return
|
val controller = mediaControllerState.controller
|
||||||
|
|
||||||
LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
|
LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
|
||||||
// active means being fully visible
|
// active means being fully visible
|
||||||
if (isClosestToTheCenterOfTheScreen.value) {
|
if (isClosestToTheCenterOfTheScreen.value) {
|
||||||
// should auto start video from settings?
|
// should auto start video from settings?
|
||||||
if (!automaticallyStartPlayback.value) {
|
if (!automaticallyStartPlayback) {
|
||||||
if (controller.isPlaying) {
|
if (controller.isPlaying) {
|
||||||
// if it is visible, it's playing but it wasn't supposed to start automatically.
|
// if it is visible, it's playing but it wasn't supposed to start automatically.
|
||||||
controller.pause()
|
controller.pause()
|
||||||
|
|||||||
+30
-200
@@ -21,225 +21,55 @@
|
|||||||
package com.vitorpamplona.amethyst.service.playback.composable
|
package com.vitorpamplona.amethyst.service.playback.composable
|
||||||
|
|
||||||
import androidx.compose.runtime.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.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GetVideoController(
|
fun GetVideoController(
|
||||||
mediaItem: LoadedMediaItem,
|
mediaItem: LoadedMediaItem,
|
||||||
muted: Boolean = false,
|
muted: Boolean = false,
|
||||||
|
play: Boolean = false,
|
||||||
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
|
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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() }
|
if (BackgroundMedia.isPlaying()) {
|
||||||
|
// There is a video playing, start this one on mute.
|
||||||
val controllerId = remember(mediaItem.src.videoUri) { MediaControllerState() }
|
state.controller.volume = 0f
|
||||||
|
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying")
|
||||||
controllerId.composed = true
|
} else {
|
||||||
|
// There is no other video playing. Use the default mute state to
|
||||||
val scope = rememberCoroutineScope()
|
// decide if sound is on or not.
|
||||||
|
state.controller.volume = if (muted) 0f else 1f
|
||||||
// Prepares a VideoPlayer from the foreground service.
|
Log.d("PlaybackService", "OnEach $muted")
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} 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) {
|
if (play) {
|
||||||
Log.d("PlaybackService", "Preparing Existing Video ${mediaItem.src.videoUri} ")
|
state.controller.playWhenReady = true
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.controller.setMediaItem(mediaItem.item)
|
||||||
|
state.controller.prepare()
|
||||||
}
|
}
|
||||||
}
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
onDispose {
|
controllerState?.let {
|
||||||
controllerId.composed = false
|
inner(it)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ fun LoadThumbAndThenVideoView(
|
|||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
nostrUriCallback: String? = null,
|
nostrUriCallback: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onDialog: ((Boolean) -> Unit)? = null,
|
onDialog: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
+14
-16
@@ -21,10 +21,8 @@
|
|||||||
package com.vitorpamplona.amethyst.service.playback.composable
|
package com.vitorpamplona.amethyst.service.playback.composable
|
||||||
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.session.MediaController
|
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@@ -34,25 +32,25 @@ class MediaControllerState(
|
|||||||
// each composable has an ID.
|
// each composable has an ID.
|
||||||
val id: String = Uuid.random().toString(),
|
val id: String = Uuid.random().toString(),
|
||||||
// This is filled after the controller returns from this class
|
// This is filled after the controller returns from this class
|
||||||
var controller: MediaController? = null,
|
var controller: Player,
|
||||||
// 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,
|
|
||||||
// visibility onscreen
|
// visibility onscreen
|
||||||
val visibility: VisibilityData = VisibilityData(),
|
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
|
@Stable
|
||||||
|
|||||||
+46
-68
@@ -20,28 +20,43 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.playback.composable
|
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.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.compose.ContentFrame
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
|
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.mediaitem.LoadedMediaItem
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun RenderVideoPlayer(
|
fun RenderVideoPlayer(
|
||||||
@@ -52,74 +67,37 @@ fun RenderVideoPlayer(
|
|||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
borderModifier: Modifier,
|
borderModifier: Modifier,
|
||||||
videoModifier: Modifier,
|
videoModifier: Modifier,
|
||||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
onDialog: (() -> Unit)? = null,
|
||||||
onDialog: ((Boolean) -> Unit)?,
|
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Box(modifier = borderModifier) {
|
Box(modifier = borderModifier) {
|
||||||
AndroidView(
|
ContentFrame(
|
||||||
modifier = videoModifier,
|
player = controllerState.controller,
|
||||||
factory = { context: Context ->
|
modifier =
|
||||||
PlayerView(context).apply {
|
videoModifier.clickable(
|
||||||
player = controllerState.controller
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
// if we alrady know the size of the frame, this forces the player to stay in the size
|
indication = null, // to prevent the ripple from the tap
|
||||||
layoutParams =
|
) { controllerVisible.value = !controllerVisible.value },
|
||||||
FrameLayout.LayoutParams(
|
surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts.
|
||||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
contentScale = contentScale,
|
||||||
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) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
|
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
|
||||||
|
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
RenderControlButtons(
|
RenderTopButtons(
|
||||||
mediaItem.src,
|
mediaData = mediaItem.src,
|
||||||
controllerState,
|
controllerState = controllerState,
|
||||||
controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
Modifier.align(Alignment.TopEnd),
|
onZoomClick = onDialog,
|
||||||
accountViewModel,
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-12
@@ -65,8 +65,7 @@ fun VideoView(
|
|||||||
dimensions: DimensionTag? = null,
|
dimensions: DimensionTag? = null,
|
||||||
blurhash: String? = null,
|
blurhash: String? = null,
|
||||||
nostrUriCallback: String? = null,
|
nostrUriCallback: String? = null,
|
||||||
onDialog: ((Boolean) -> Unit)? = null,
|
onDialog: (() -> Unit)? = null,
|
||||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
alwaysShowVideo: Boolean = false,
|
alwaysShowVideo: Boolean = false,
|
||||||
) {
|
) {
|
||||||
@@ -79,7 +78,7 @@ fun VideoView(
|
|||||||
Modifier
|
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
|
@Composable
|
||||||
@@ -96,11 +95,10 @@ fun VideoView(
|
|||||||
dimensions: DimensionTag? = null,
|
dimensions: DimensionTag? = null,
|
||||||
blurhash: String? = null,
|
blurhash: String? = null,
|
||||||
nostrUriCallback: String? = null,
|
nostrUriCallback: String? = null,
|
||||||
onDialog: ((Boolean) -> Unit)? = null,
|
onDialog: (() -> Unit)? = null,
|
||||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
alwaysShowVideo: Boolean = false,
|
alwaysShowVideo: Boolean = false,
|
||||||
showControls: Boolean = true,
|
showControls: Boolean = true,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
val automaticallyStartPlayback =
|
val automaticallyStartPlayback =
|
||||||
remember {
|
remember {
|
||||||
@@ -137,9 +135,8 @@ fun VideoView(
|
|||||||
artworkUri = artworkUri,
|
artworkUri = artworkUri,
|
||||||
authorName = authorName,
|
authorName = authorName,
|
||||||
nostrUriCallback = nostrUriCallback,
|
nostrUriCallback = nostrUriCallback,
|
||||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
automaticallyStartPlayback = automaticallyStartPlayback.value,
|
||||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
onZoom = onDialog,
|
||||||
onDialog = onDialog,
|
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
showControls = showControls,
|
showControls = showControls,
|
||||||
)
|
)
|
||||||
@@ -184,9 +181,8 @@ fun VideoView(
|
|||||||
artworkUri = artworkUri,
|
artworkUri = artworkUri,
|
||||||
authorName = authorName,
|
authorName = authorName,
|
||||||
nostrUriCallback = nostrUriCallback,
|
nostrUriCallback = nostrUriCallback,
|
||||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
automaticallyStartPlayback = automaticallyStartPlayback.value,
|
||||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
onZoom = onDialog,
|
||||||
onDialog = onDialog,
|
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
showControls = showControls,
|
showControls = showControls,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-6
@@ -21,7 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.service.playback.composable
|
package com.vitorpamplona.amethyst.service.playback.composable
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -46,9 +46,9 @@ fun VideoViewInner(
|
|||||||
artworkUri: String? = null,
|
artworkUri: String? = null,
|
||||||
authorName: String? = null,
|
authorName: String? = null,
|
||||||
nostrUriCallback: String? = null,
|
nostrUriCallback: String? = null,
|
||||||
automaticallyStartPlayback: State<Boolean>,
|
automaticallyStartPlayback: Boolean,
|
||||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
controllerVisible: MutableState<Boolean> = mutableStateOf(true),
|
||||||
onDialog: ((Boolean) -> Unit)? = null,
|
onZoom: (() -> Unit)? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
||||||
@@ -80,8 +80,8 @@ fun VideoViewInner(
|
|||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
borderModifier = borderModifier,
|
borderModifier = borderModifier,
|
||||||
videoModifier = videoModifier,
|
videoModifier = videoModifier,
|
||||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
controllerVisible = controllerVisible,
|
||||||
onDialog = onDialog,
|
onDialog = onZoom,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-2
@@ -41,20 +41,38 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.vitorpamplona.amethyst.R
|
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.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun MuteButtonPreview() {
|
||||||
|
ThemeComparisonColumn {
|
||||||
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
|
MuteButton(
|
||||||
|
controllerVisible = remember { mutableStateOf(true) },
|
||||||
|
startingMuteState = true,
|
||||||
|
modifier = Modifier,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MuteButton(
|
fun MuteButton(
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
startingMuteState: Boolean,
|
startingMuteState: Boolean,
|
||||||
modifier: Modifier,
|
modifier: Modifier = Modifier,
|
||||||
toggle: (Boolean) -> Unit,
|
toggle: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val holdOn =
|
val holdOn =
|
||||||
@@ -83,7 +101,7 @@ fun MuteButton(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.fillMaxSize(0.6f)
|
.fillMaxSize(0.7f)
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
)
|
)
|
||||||
|
|||||||
+36
-3
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
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.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -31,23 +33,54 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.vitorpamplona.amethyst.ui.note.EnablePiP
|
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.PinBottomIconSize
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PictureInPictureButtonPreview() {
|
||||||
|
ThemeComparisonColumn {
|
||||||
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
|
PictureInPictureButton(
|
||||||
|
controllerVisible = remember { mutableStateOf(true) },
|
||||||
|
modifier = Modifier,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PictureInPictureButton(
|
fun PictureInPictureButton(
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
modifier: Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
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(
|
AnimatedVisibility(
|
||||||
visible = controllerVisible.value,
|
visible = isAllowed && controllerVisible.value,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
enter = remember { fadeIn() },
|
enter = remember { fadeIn() },
|
||||||
exit = remember { fadeOut() },
|
exit = remember { fadeOut() },
|
||||||
@@ -56,7 +89,7 @@ fun PictureInPictureButton(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.fillMaxSize(0.6f)
|
.fillMaxSize(0.7f)
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
)
|
)
|
||||||
|
|||||||
+130
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+78
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-10
@@ -18,18 +18,35 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.playback.pip
|
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||||
|
|
||||||
import android.app.Activity
|
import androidx.annotation.OptIn
|
||||||
import android.graphics.Rect
|
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(
|
@OptIn(UnstableApi::class)
|
||||||
ratio: Float?,
|
@Composable
|
||||||
bounds: Rect?,
|
fun RenderCenterButtons(
|
||||||
|
controllerState: MediaControllerState,
|
||||||
|
controllerVisible: MutableState<Boolean>,
|
||||||
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
if (!isInPictureInPictureMode) {
|
val state = rememberPlayPauseButtonState(controllerState.controller)
|
||||||
enterPictureInPictureMode(makePipParams(ratio, bounds))
|
|
||||||
} else {
|
AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) {
|
||||||
setPictureInPictureParams(makePipParams(ratio, bounds))
|
state.onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.showPlay) {
|
||||||
|
LaunchedEffect(state.showPlay) {
|
||||||
|
delay(2000)
|
||||||
|
controllerVisible.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-82
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+167
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-2
@@ -38,26 +38,43 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
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.PinBottomIconSize
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AnimatedSaveButtonPreview() {
|
||||||
|
ThemeComparisonColumn {
|
||||||
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
|
AnimatedSaveButton(
|
||||||
|
controllerVisible = remember { mutableStateOf(true) },
|
||||||
|
modifier = Modifier,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnimatedSaveButton(
|
fun AnimatedSaveButton(
|
||||||
controllerVisible: State<Boolean>,
|
controllerVisible: State<Boolean>,
|
||||||
modifier: Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onSaveClick: (localContext: Context) -> Unit,
|
onSaveClick: (localContext: Context) -> Unit,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -77,7 +94,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.fillMaxSize(0.6f)
|
.fillMaxSize(0.7f)
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
)
|
)
|
||||||
|
|||||||
+23
-7
@@ -40,16 +40,32 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
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.PinBottomIconSize
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
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
|
@Composable
|
||||||
fun AnimatedShareButton(
|
fun AnimatedShareButton(
|
||||||
controllerVisible: State<Boolean>,
|
controllerVisible: State<Boolean>,
|
||||||
modifier: Modifier,
|
modifier: Modifier = Modifier,
|
||||||
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
|
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -64,13 +80,13 @@ fun AnimatedShareButton(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
|
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
|
||||||
Box(modifier = PinBottomIconSize) {
|
Box(modifier = PinBottomIconSize, contentAlignment = Alignment.Center) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
modifier =
|
||||||
.clip(CircleShape)
|
Modifier
|
||||||
.fillMaxSize(0.6f)
|
.clip(CircleShape)
|
||||||
.align(Alignment.Center)
|
.fillMaxSize(0.7f)
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
)
|
)
|
||||||
|
|
||||||
val popupExpanded = remember { mutableStateOf(false) }
|
val popupExpanded = remember { mutableStateOf(false) }
|
||||||
|
|||||||
+16
-14
@@ -30,7 +30,6 @@ import androidx.compose.runtime.MutableState
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.composed
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.boundsInWindow
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
@@ -40,7 +39,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
// This keeps the position of all visible videos in the current screen.
|
// This keeps the position of all visible videos in the current screen.
|
||||||
val trackingVideos = mutableListOf<MediaControllerState>()
|
val trackingVideos = mutableSetOf<MediaControllerState>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function selects only one Video to be active. The video that is closest to the center of the
|
* This function selects only one Video to be active. The video that is closest to the center of the
|
||||||
@@ -52,17 +51,21 @@ fun VideoPlayerActiveMutex(
|
|||||||
inner: @Composable (Modifier, MutableState<Boolean>) -> Unit,
|
inner: @Composable (Modifier, MutableState<Boolean>) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Is the current video the closest to the center?
|
// Is the current video the closest to the center?
|
||||||
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(false) }
|
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) }
|
||||||
|
|
||||||
// Keep track of all available videos.
|
// Keep track of all available videos.
|
||||||
DisposableEffect(key1 = controller) {
|
DisposableEffect(key1 = controller) {
|
||||||
trackingVideos.add(controller)
|
trackingVideos.add(controller)
|
||||||
onDispose { trackingVideos.remove(controller) }
|
onDispose {
|
||||||
|
trackingVideos.remove(controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
|
||||||
val videoModifier =
|
val videoModifier =
|
||||||
remember(controller) {
|
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.bounds = bounds
|
||||||
controller.visibility.distanceToCenter = distanceToCenter
|
controller.visibility.distanceToCenter = distanceToCenter
|
||||||
|
|
||||||
@@ -93,15 +96,14 @@ fun VideoPlayerActiveMutex(
|
|||||||
inner(videoModifier, isClosestToTheCenterOfTheScreen)
|
inner(videoModifier, isClosestToTheCenterOfTheScreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier =
|
fun Modifier.onVisiblePositionChanges(
|
||||||
composed {
|
view: View,
|
||||||
val view = LocalView.current
|
onVisiblePosition: (Rect, Float?) -> Unit,
|
||||||
|
): Modifier =
|
||||||
onGloballyPositioned { coordinates ->
|
onGloballyPositioned { coordinates ->
|
||||||
val bounds = coordinates.boundsInWindow()
|
val bounds = coordinates.boundsInWindow()
|
||||||
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
|
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
|
||||||
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
|
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(
|
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(
|
||||||
|
|||||||
+13
-20
@@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.session.MediaController
|
|
||||||
import com.linc.audiowaveform.infiniteLinearGradient
|
import com.linc.audiowaveform.infiniteLinearGradient
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||||
@@ -56,31 +55,25 @@ fun Waveform(
|
|||||||
|
|
||||||
val restartFlow = remember { mutableIntStateOf(0) }
|
val restartFlow = remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val myController = mediaControllerState.controller
|
|
||||||
|
|
||||||
// Keeps the screen on while playing and viewing videos.
|
// Keeps the screen on while playing and viewing videos.
|
||||||
if (myController != null) {
|
DisposableEffect(key1 = mediaControllerState.controller) {
|
||||||
DisposableEffect(key1 = myController) {
|
val listener =
|
||||||
val listener =
|
object : Player.Listener {
|
||||||
object : Player.Listener {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
// doesn't consider the mutex because the screen can turn off if the video
|
||||||
// doesn't consider the mutex because the screen can turn off if the video
|
// being played in the mutex is not visible.
|
||||||
// being played in the mutex is not visible.
|
if (isPlaying) {
|
||||||
if (isPlaying) {
|
restartFlow.intValue += 1
|
||||||
restartFlow.intValue += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
myController.addListener(listener)
|
mediaControllerState.controller.addListener(listener)
|
||||||
onDispose { myController.removeListener(listener) }
|
onDispose { mediaControllerState.controller.removeListener(listener) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(key1 = restartFlow.intValue) {
|
LaunchedEffect(key1 = restartFlow.intValue) {
|
||||||
mediaControllerState.controller?.let {
|
pollCurrentDuration(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value }
|
||||||
pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -90,7 +83,7 @@ fun Waveform(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pollCurrentDuration(controller: MediaController) =
|
private fun pollCurrentDuration(controller: Player) =
|
||||||
flow {
|
flow {
|
||||||
while (controller.currentPosition <= controller.duration) {
|
while (controller.currentPosition <= controller.duration) {
|
||||||
emit(controller.currentPosition / controller.duration.toFloat())
|
emit(controller.currentPosition / controller.duration.toFloat())
|
||||||
|
|||||||
+20
-13
@@ -20,36 +20,43 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.playback.pip
|
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.composable.MediaControllerState
|
||||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
object BackgroundMedia {
|
object BackgroundMedia {
|
||||||
// background playing mutex.
|
// 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 hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState)
|
||||||
|
|
||||||
fun removeBackgroundControllerAndReleaseIt() {
|
fun removeBackgroundControllerAndReleaseIt() {
|
||||||
bgInstance.value?.let {
|
bgInstance = null
|
||||||
PlaybackServiceClient.removeController(it)
|
|
||||||
bgInstance.tryEmit(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
|
fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
|
||||||
bgInstance.tryEmit(mediaControllerState)
|
bgInstance = mediaControllerState
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBackground(mediaControllerState: MediaControllerState) {
|
fun clearBackground(mediaControllerState: MediaControllerState) {
|
||||||
if (bgInstance.value == mediaControllerState) {
|
if (bgInstance == mediaControllerState) {
|
||||||
bgInstance.tryEmit(null)
|
bgInstance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegisterBackgroundMedia(controllerState: MediaControllerState) {
|
||||||
|
DisposableEffect(controllerState) {
|
||||||
|
BackgroundMedia.switchKeepPlaying(controllerState)
|
||||||
|
onDispose {
|
||||||
|
BackgroundMedia.clearBackground(controllerState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
@@ -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
|
||||||
|
}
|
||||||
+21
-23
@@ -20,20 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.playback.pip
|
package com.vitorpamplona.amethyst.service.playback.pip
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Rational
|
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(
|
fun makePipParams(
|
||||||
ratio: Float?,
|
ratio: Float?,
|
||||||
@@ -49,18 +40,25 @@ fun makePipParams(
|
|||||||
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
|
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
@Composable
|
fun Activity.makePipParams(
|
||||||
fun rememberIsInPipMode(): Boolean {
|
isPlaying: Boolean,
|
||||||
val activity = LocalContext.current.getActivity()
|
isMuted: Boolean,
|
||||||
var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
|
ratio: Float?,
|
||||||
DisposableEffect(activity) {
|
bounds: Rect?,
|
||||||
val observer =
|
): PictureInPictureParams =
|
||||||
Consumer<PictureInPictureModeChangedInfo> { info ->
|
PictureInPictureParams
|
||||||
pipMode = info.isInPictureInPictureMode
|
.Builder()
|
||||||
|
.apply {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
setAutoEnterEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
activity.addOnPictureInPictureModeChangedListener(observer)
|
setActions(
|
||||||
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
|
listOf(
|
||||||
}
|
createPlayPauseAction(this@makePipParams, isPlaying),
|
||||||
return pipMode
|
createMuteAction(this@makePipParams, isMuted),
|
||||||
}
|
),
|
||||||
|
)
|
||||||
|
bounds?.let { setSourceRectHint(bounds) }
|
||||||
|
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
|
||||||
|
}.build()
|
||||||
|
|||||||
+20
-25
@@ -23,15 +23,18 @@ package com.vitorpamplona.amethyst.service.playback.pip
|
|||||||
import android.app.ActivityOptions
|
import android.app.ActivityOptions
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.media3.common.util.UnstableApi
|
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
|
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||||
|
|
||||||
class PipVideoActivity : ComponentActivity() {
|
class PipVideoActivity : ComponentActivity() {
|
||||||
@@ -39,22 +42,22 @@ class PipVideoActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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 {
|
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(
|
GetMediaItem(mediaItemData) { mediaItem ->
|
||||||
isInPictureInPictureMode: Boolean,
|
GetVideoController(mediaItem, muted, true) { controllerState ->
|
||||||
newConfig: Configuration,
|
RegisterBackgroundMedia(controllerState)
|
||||||
) {
|
RegisterControllerReceiver(controllerState)
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
WatchControllerForActions(mediaItemData, controllerState)
|
||||||
|
RenderPipVideo(controllerState, mediaItemData.waveformData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
@@ -67,11 +70,6 @@ class PipVideoActivity : ComponentActivity() {
|
|||||||
super.finish()
|
super.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
|
||||||
super.onUserLeaveHint()
|
|
||||||
finishAndRemoveTask()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun callIn(
|
fun callIn(
|
||||||
videoData: MediaItemData,
|
videoData: MediaItemData,
|
||||||
@@ -86,13 +84,10 @@ class PipVideoActivity : ComponentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
ActivityOptions.makeBasic()
|
ActivityOptions.makeBasic()
|
||||||
}
|
}
|
||||||
options.setLaunchBounds(videoBounds)
|
|
||||||
|
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
Intent(context, PipVideoActivity::class.java).apply {
|
Intent(context.applicationContext, PipVideoActivity::class.java).apply {
|
||||||
putExtras(IntentExtras.createBundle(videoData, videoBounds))
|
putExtras(IntentExtras.createBundle(videoData, videoBounds))
|
||||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
|
||||||
},
|
},
|
||||||
options.toBundle(),
|
options.toBundle(),
|
||||||
)
|
)
|
||||||
|
|||||||
+73
-76
@@ -20,109 +20,58 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.playback.pip
|
package com.vitorpamplona.amethyst.service.playback.pip
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context.RECEIVER_EXPORTED
|
||||||
import android.content.Intent
|
import android.os.Build
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.compose.ContentFrame
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.compose.state.rememberMuteButtonState
|
||||||
|
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
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.MediaControllerState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
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.composable.wavefront.Waveform
|
||||||
|
import com.vitorpamplona.amethyst.service.playback.pip.makePipParams
|
||||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
|
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)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PipVideo(
|
fun RenderPipVideo(
|
||||||
controller: MediaControllerState,
|
controller: MediaControllerState,
|
||||||
waveformData: WaveformData?,
|
waveformData: WaveformData?,
|
||||||
) {
|
) {
|
||||||
DisposableEffect(controller) {
|
|
||||||
BackgroundMedia.switchKeepPlaying(controller)
|
|
||||||
onDispose {
|
|
||||||
BackgroundMedia.clearBackground(controller)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val ratio =
|
|
||||||
controller.currrentMedia()?.let {
|
|
||||||
MediaAspectRatioCache.get(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val modifier =
|
val modifier =
|
||||||
if (ratio != null) {
|
remember {
|
||||||
Modifier.aspectRatio(ratio)
|
val ratio =
|
||||||
} else {
|
controller.currrentMedia()?.let {
|
||||||
Modifier
|
MediaAspectRatioCache.get(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratio != null) {
|
||||||
|
Modifier.aspectRatio(ratio)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier, contentAlignment = Alignment.Center) {
|
Box(modifier, contentAlignment = Alignment.Center) {
|
||||||
AndroidView(
|
ContentFrame(
|
||||||
modifier = Modifier,
|
player = controller.controller,
|
||||||
factory = { context: Context ->
|
keepContentOnReset = true,
|
||||||
PlayerView(context).apply {
|
contentScale = ContentScale.Crop,
|
||||||
clipToOutline = true
|
|
||||||
player = controller.controller
|
|
||||||
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
|
|
||||||
|
|
||||||
controllerAutoShow = false
|
|
||||||
useController = false
|
|
||||||
|
|
||||||
hideController()
|
|
||||||
|
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
|
|
||||||
|
|
||||||
controller.controller?.playWhenReady = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+95
@@ -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)
|
||||||
|
}
|
||||||
+13
-9
@@ -45,7 +45,7 @@ class ExoPlayerPool(
|
|||||||
// Exists to avoid exceptions stopping the coroutine
|
// Exists to avoid exceptions stopping the coroutine
|
||||||
val exceptionHandler =
|
val exceptionHandler =
|
||||||
CoroutineExceptionHandler { _, throwable ->
|
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)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
|
||||||
@@ -75,15 +75,19 @@ class ExoPlayerPool(
|
|||||||
|
|
||||||
suspend fun releasePlayer(player: ExoPlayer) {
|
suspend fun releasePlayer(player: ExoPlayer) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
player.pause()
|
if (!player.isReleased) {
|
||||||
player.stop()
|
player.pause()
|
||||||
player.clearMediaItems()
|
player.stop()
|
||||||
if (playerPool.size < poolSize) {
|
player.clearVideoSurface()
|
||||||
if (!playerPool.contains(player)) {
|
player.clearMediaItems()
|
||||||
playerPool.add(player)
|
|
||||||
|
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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-9
@@ -36,7 +36,6 @@ import androidx.media3.session.MediaSession
|
|||||||
import com.google.common.util.concurrent.Futures
|
import com.google.common.util.concurrent.Futures
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -99,10 +98,11 @@ class MediaSessionPool(
|
|||||||
.Builder(context, exoPlayerPool.acquirePlayer(context))
|
.Builder(context, exoPlayerPool.acquirePlayer(context))
|
||||||
.apply {
|
.apply {
|
||||||
setBitmapLoader(
|
setBitmapLoader(
|
||||||
DataSourceBitmapLoader(
|
DataSourceBitmapLoader
|
||||||
DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(),
|
.Builder(context)
|
||||||
OkHttpDataSource.Factory(okHttpClient),
|
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
|
||||||
),
|
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||||
|
.build(),
|
||||||
)
|
)
|
||||||
setId(id)
|
setId(id)
|
||||||
setCallback(globalCallback)
|
setCallback(globalCallback)
|
||||||
@@ -125,11 +125,9 @@ class MediaSessionPool(
|
|||||||
session.player.removeListener(listener.playerListener)
|
session.player.removeListener(listener.playerListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.remove(session.id)
|
|
||||||
playingMap.remove(session.id)
|
playingMap.remove(session.id)
|
||||||
|
cache.remove(session.id)
|
||||||
session.release()
|
session.release()
|
||||||
exoPlayerPool.releasePlayerAsync(session.player as ExoPlayer)
|
|
||||||
|
|
||||||
cleanupUnused()
|
cleanupUnused()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +142,6 @@ class MediaSessionPool(
|
|||||||
// but not connected yet.
|
// but not connected yet.
|
||||||
// delay(10000)
|
// delay(10000)
|
||||||
snap.values.forEach {
|
snap.values.forEach {
|
||||||
Log.d("MediaSessionPoll", "Clean Up Unused ${it.session.connectedControllers.size} ${it.session.id}")
|
|
||||||
if (it.session.connectedControllers.isEmpty()) {
|
if (it.session.connectedControllers.isEmpty()) {
|
||||||
releaseSession(it.session)
|
releaseSession(it.session)
|
||||||
counter++
|
counter++
|
||||||
|
|||||||
+2
-2
@@ -117,7 +117,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
// if nothing is pl
|
// if nothing is pl
|
||||||
if (playing.isEmpty() && BackgroundMedia.hasInstance()) {
|
if (playing.isEmpty() && BackgroundMedia.hasInstance()) {
|
||||||
BackgroundMedia.bgInstance.value?.id?.let { id ->
|
BackgroundMedia.bgInstance?.id?.let { id ->
|
||||||
(poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let {
|
(poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let {
|
||||||
super.onUpdateNotification(it, startInForegroundRequired)
|
super.onUpdateNotification(it, startInForegroundRequired)
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playing.forEachIndexed { idx, it ->
|
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)
|
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-67
@@ -27,91 +27,77 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
object PlaybackServiceClient {
|
object PlaybackServiceClient {
|
||||||
val executorService: ExecutorService = Executors.newCachedThreadPool()
|
val executorService: ExecutorService = Executors.newCachedThreadPool()
|
||||||
|
|
||||||
fun removeController(mediaControllerState: MediaControllerState) {
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
mediaControllerState.active.value = false
|
fun controllerAsFlow(
|
||||||
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,
|
|
||||||
videoUri: String,
|
videoUri: String,
|
||||||
proxyPort: Int? = 0,
|
proxyPort: Int? = 0,
|
||||||
keepPlaying: Boolean = true,
|
keepPlaying: Boolean = true,
|
||||||
context: Context,
|
context: Context,
|
||||||
onReady: (MediaControllerState) -> Unit,
|
) = callbackFlow {
|
||||||
) {
|
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
val id = Uuid.random().toString()
|
||||||
|
|
||||||
mediaControllerState.active.value = true
|
val bundle =
|
||||||
|
Bundle().apply {
|
||||||
try {
|
// link the id with the client's id to make sure it can return the
|
||||||
val bundle =
|
// same session on background media.
|
||||||
Bundle().apply {
|
putString("id", id)
|
||||||
// link the id with the client's id to make sure it can return the
|
putBoolean("keepPlaying", keepPlaying)
|
||||||
// same session on background media.
|
proxyPort?.let {
|
||||||
putString("id", mediaControllerState.id)
|
putInt("proxyPort", it)
|
||||||
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 =
|
val controllerFuture =
|
||||||
MediaController
|
MediaController
|
||||||
.Builder(appContext, session)
|
.Builder(appContext, session)
|
||||||
.setConnectionHints(bundle)
|
.setConnectionHints(bundle)
|
||||||
.buildAsync()
|
.buildAsync()
|
||||||
|
|
||||||
Log.d("PlaybackService", "Preparing Controller ${mediaControllerState.id} $videoUri")
|
Log.d("PlaybackService", "Preparing Controller $id $videoUri")
|
||||||
|
|
||||||
controllerFuture.addListener(
|
controllerFuture.addListener(
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
val controller = controllerFuture.get()
|
val controller = controllerFuture.get(5, TimeUnit.SECONDS)
|
||||||
mediaControllerState.controller = controller
|
|
||||||
|
|
||||||
// checks if the player is still active before engaging further
|
Log.d("PlaybackService", "Controller Ready $id $videoUri")
|
||||||
if (mediaControllerState.isActive()) {
|
|
||||||
onReady(mediaControllerState)
|
// checks if the player is still active before engaging further
|
||||||
} else {
|
trySend(
|
||||||
removeController(mediaControllerState)
|
MediaControllerState(
|
||||||
}
|
id = id,
|
||||||
} catch (e: Exception) {
|
controller = controller,
|
||||||
if (e is CancellationException) throw e
|
),
|
||||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
)
|
||||||
}
|
} catch (e: Exception) {
|
||||||
},
|
Log.e("Playback Client", "Failed to load Playback Client for $id $videoUri", e)
|
||||||
executorService,
|
close(e)
|
||||||
)
|
}
|
||||||
} catch (e: Exception) {
|
},
|
||||||
if (e is CancellationException) throw e
|
executorService,
|
||||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-127
@@ -28,6 +28,8 @@ import android.widget.Toast
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
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
|
||||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -125,9 +127,7 @@ fun ZoomableImageDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Surface(Modifier.fillMaxSize()) {
|
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) {
|
Box(
|
||||||
SlidingCarousel(
|
Modifier
|
||||||
pagerState = pagerState,
|
.fillMaxSize()
|
||||||
) { index ->
|
.clickable(
|
||||||
allImages.getOrNull(index)?.let {
|
onClick = {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
controllerVisible.value = !controllerVisible.value
|
||||||
RenderImageOrVideo(
|
},
|
||||||
content = it,
|
),
|
||||||
roundedCorner = false,
|
Alignment.TopCenter,
|
||||||
isFiniteHeight = true,
|
) {
|
||||||
controllerVisible = controllerVisible,
|
if (allImages.size > 1) {
|
||||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
SlidingCarousel(
|
||||||
onToggleControllerVisibility = {
|
pagerState = pagerState,
|
||||||
controllerVisible.value = !controllerVisible.value
|
) { index ->
|
||||||
},
|
allImages.getOrNull(index)?.let {
|
||||||
accountViewModel = accountViewModel,
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
)
|
RenderImageOrVideo(
|
||||||
|
content = it,
|
||||||
|
roundedCorner = false,
|
||||||
|
isFiniteHeight = true,
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
RenderImageOrVideo(
|
||||||
RenderImageOrVideo(
|
content = imageUrl,
|
||||||
content = imageUrl,
|
roundedCorner = false,
|
||||||
roundedCorner = false,
|
isFiniteHeight = true,
|
||||||
isFiniteHeight = true,
|
controllerVisible = controllerVisible,
|
||||||
controllerVisible = controllerVisible,
|
accountViewModel = accountViewModel,
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
AnimatedVisibility(
|
||||||
|
visible = controllerVisible.value,
|
||||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
enter = remember { fadeIn() },
|
||||||
val popupExpanded = remember { mutableStateOf(false) }
|
exit = remember { fadeOut() },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = Size15dp, vertical = Size10dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = spacedBy(Size10dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { popupExpanded.value = true },
|
onClick = onDismiss,
|
||||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Share,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
modifier = Size20Modifier,
|
contentDescription = stringRes(R.string.back),
|
||||||
contentDescription = stringRes(R.string.quick_action_share),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
val localContext = LocalContext.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||||
|
if (myContent is MediaUrlImage || myContent is MediaLocalImage) {
|
||||||
|
val popupExpanded = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val writeStoragePermissionState =
|
OutlinedButton(
|
||||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
onClick = { popupExpanded.value = true },
|
||||||
if (isGranted) {
|
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||||
scope.launch {
|
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
) {
|
||||||
}
|
Icon(
|
||||||
scope.launch {
|
imageVector = Icons.Default.Share,
|
||||||
Toast
|
modifier = Size20Modifier,
|
||||||
.makeText(
|
contentDescription = stringRes(R.string.quick_action_share),
|
||||||
localContext,
|
)
|
||||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
|
||||||
Toast.LENGTH_SHORT,
|
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
|
||||||
onClick = {
|
val localContext = LocalContext.current
|
||||||
if (
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
val scope = rememberCoroutineScope()
|
||||||
writeStoragePermissionState.status.isGranted
|
|
||||||
|
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) {
|
Icon(
|
||||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
imageVector = Icons.Default.Download,
|
||||||
}
|
modifier = Size20Modifier,
|
||||||
scope.launch {
|
contentDescription = stringRes(R.string.save_to_gallery),
|
||||||
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),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Download,
|
|
||||||
modifier = Size20Modifier,
|
|
||||||
contentDescription = stringRes(R.string.save_to_gallery),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,11 +354,8 @@ private fun RenderImageOrVideo(
|
|||||||
roundedCorner: Boolean,
|
roundedCorner: Boolean,
|
||||||
isFiniteHeight: Boolean,
|
isFiniteHeight: Boolean,
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
|
||||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
val automaticallyStartPlayback = remember { mutableStateOf(true) }
|
|
||||||
val contentScale =
|
val contentScale =
|
||||||
if (isFiniteHeight) {
|
if (isFiniteHeight) {
|
||||||
ContentScale.Fit
|
ContentScale.Fit
|
||||||
@@ -368,9 +372,7 @@ private fun RenderImageOrVideo(
|
|||||||
.zoomable(
|
.zoomable(
|
||||||
rememberZoomState(),
|
rememberZoomState(),
|
||||||
onTap = {
|
onTap = {
|
||||||
if (onToggleControllerVisibility != null) {
|
controllerVisible.value = !controllerVisible.value
|
||||||
onToggleControllerVisibility()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -412,8 +414,9 @@ private fun RenderImageOrVideo(
|
|||||||
authorName = content.authorName,
|
authorName = content.authorName,
|
||||||
borderModifier = borderModifier,
|
borderModifier = borderModifier,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
nostrUriCallback = content.uri,
|
||||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
automaticallyStartPlayback = true,
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -426,9 +429,7 @@ private fun RenderImageOrVideo(
|
|||||||
.zoomable(
|
.zoomable(
|
||||||
rememberZoomState(),
|
rememberZoomState(),
|
||||||
onTap = {
|
onTap = {
|
||||||
if (onToggleControllerVisibility != null) {
|
controllerVisible.value = !controllerVisible.value
|
||||||
onToggleControllerVisibility()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -471,8 +472,9 @@ private fun RenderImageOrVideo(
|
|||||||
authorName = content.authorName,
|
authorName = content.authorName,
|
||||||
borderModifier = borderModifier,
|
borderModifier = borderModifier,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
nostrUriCallback = content.uri,
|
||||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
automaticallyStartPlayback = true,
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -166,9 +166,7 @@ fun ZoomableContentView(
|
|||||||
roundedCorner = roundedCorner,
|
roundedCorner = roundedCorner,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
nostrUriCallback = content.uri,
|
nostrUriCallback = content.uri,
|
||||||
onDialog = {
|
onDialog = { dialogOpen = true },
|
||||||
dialogOpen = true
|
|
||||||
},
|
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -701,7 +699,6 @@ fun ShareMediaAction(
|
|||||||
) {
|
) {
|
||||||
if (content is MediaUrlContent) {
|
if (content is MediaUrlContent) {
|
||||||
ShareMediaAction(
|
ShareMediaAction(
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
popupExpanded = popupExpanded,
|
popupExpanded = popupExpanded,
|
||||||
videoUri = content.url,
|
videoUri = content.url,
|
||||||
postNostrUri = content.uri,
|
postNostrUri = content.uri,
|
||||||
@@ -711,10 +708,10 @@ fun ShareMediaAction(
|
|||||||
mimeType = content.mimeType,
|
mimeType = content.mimeType,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
content = content,
|
content = content,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
} else if (content is MediaPreloadedContent) {
|
} else if (content is MediaPreloadedContent) {
|
||||||
ShareMediaAction(
|
ShareMediaAction(
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
popupExpanded = popupExpanded,
|
popupExpanded = popupExpanded,
|
||||||
videoUri = content.localFile?.toUri().toString(),
|
videoUri = content.localFile?.toUri().toString(),
|
||||||
postNostrUri = content.uri,
|
postNostrUri = content.uri,
|
||||||
@@ -724,6 +721,7 @@ fun ShareMediaAction(
|
|||||||
mimeType = content.mimeType,
|
mimeType = content.mimeType,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
content = content,
|
content = content,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -731,7 +729,6 @@ fun ShareMediaAction(
|
|||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareMediaAction(
|
fun ShareMediaAction(
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
popupExpanded: MutableState<Boolean>,
|
popupExpanded: MutableState<Boolean>,
|
||||||
videoUri: String?,
|
videoUri: String?,
|
||||||
postNostrUri: String?,
|
postNostrUri: String?,
|
||||||
@@ -741,6 +738,7 @@ fun ShareMediaAction(
|
|||||||
mimeType: String?,
|
mimeType: String?,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
content: BaseMediaContent? = null,
|
content: BaseMediaContent? = null,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
val scope = accountViewModel.viewModelScope
|
val scope = accountViewModel.viewModelScope
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.note.types
|
package com.vitorpamplona.amethyst.ui.note.types
|
||||||
|
|
||||||
import android.content.Context
|
import android.graphics.Rect
|
||||||
import android.view.View
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import androidx.annotation.OptIn
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -36,7 +36,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -44,23 +44,34 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.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.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
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.GetVideoController
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
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.GetMediaItem
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
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.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.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@@ -140,6 +151,7 @@ fun RenderAudioWithWaveform(
|
|||||||
|
|
||||||
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Row(
|
Row(
|
||||||
|
Modifier.fillMaxWidth().height(100.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
GetMediaItem(
|
GetMediaItem(
|
||||||
@@ -189,35 +201,22 @@ fun RenderVoicePlayer(
|
|||||||
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
|
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(modifier = borderModifier) {
|
Box(modifier = borderModifier) {
|
||||||
AndroidView(
|
ContentFrame(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(100.dp),
|
.height(100.dp)
|
||||||
factory = { context: Context ->
|
.onGloballyPositioned { coordinates ->
|
||||||
PlayerView(context).apply {
|
val bounds = coordinates.boundsInWindow()
|
||||||
player = controllerState.controller
|
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
|
||||||
// if we already know the size of the frame, this forces the player to stay in the size
|
controllerState.visibility.bounds = boundRect
|
||||||
layoutParams =
|
}.clickable(
|
||||||
FrameLayout.LayoutParams(
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
indication = null, // to prevent the ripple from the tap
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
) { controllerVisible.value = !controllerVisible.value },
|
||||||
)
|
player = controllerState.controller,
|
||||||
|
keepContentOnReset = true,
|
||||||
setBackgroundColor(Color.Transparent.toArgb())
|
surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts.
|
||||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
|
||||||
|
|
||||||
controllerAutoShow = false
|
|
||||||
useController = true
|
|
||||||
hideController()
|
|
||||||
|
|
||||||
setControllerVisibilityListener(
|
|
||||||
PlayerView.ControllerVisibilityListener { visible ->
|
|
||||||
controllerVisible.value = visible == View.VISIBLE
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
|
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -225,71 +224,38 @@ fun RenderVoicePlayer(
|
|||||||
waveform?.let { Waveform(it, controllerState, Modifier) }
|
waveform?.let { Waveform(it, controllerState, Modifier) }
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderControlButtons(
|
RenderTopButtonsForVoice(
|
||||||
mediaItem.src,
|
mediaData = mediaItem.src,
|
||||||
controllerState,
|
controllerState = controllerState,
|
||||||
controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
Modifier.align(Alignment.TopEnd),
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayPauseButton(controllerState: MediaControllerState) {
|
fun PlayPauseButton(controllerState: MediaControllerState) {
|
||||||
val controller = controllerState.controller
|
val state = rememberPlayPauseButtonState(controllerState.controller)
|
||||||
var isPlaying by remember(controller) {
|
|
||||||
mutableStateOf(controllerState.isPlaying())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller != null) {
|
// Play/Pause Button
|
||||||
val view = LocalView.current
|
IconButton(
|
||||||
// Keeps the screen on while playing and viewing videos.
|
onClick = {
|
||||||
DisposableEffect(key1 = controller, key2 = view) {
|
if (controllerState.controller.isPlaying) {
|
||||||
val listener =
|
controllerState.controller.pause()
|
||||||
object : Player.Listener {
|
} else {
|
||||||
override fun onIsPlayingChanged(playing: Boolean) {
|
controllerState.controller.play()
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
modifier = Size75Modifier,
|
||||||
// Play/Pause Button
|
) {
|
||||||
IconButton(
|
Icon(
|
||||||
onClick = {
|
imageVector = if (state.showPlay) Icons.Default.PlayCircleOutline else Icons.Default.PauseCircleOutline,
|
||||||
if (controller.isPlaying) {
|
contentDescription = if (state.showPlay) stringResource(R.string.play) else stringResource(R.string.pause),
|
||||||
controller.pause()
|
modifier = Size50Modifier,
|
||||||
} else {
|
tint = Color.White,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,3 +309,90 @@ fun RenderAudioFromIMeta(
|
|||||||
nav = nav,
|
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 ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp)
|
||||||
val ChatHeadlineBorders = StdPadding
|
val ChatHeadlineBorders = StdPadding
|
||||||
|
|
||||||
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
|
val VolumeBottomIconSize = Modifier.size(60.dp).padding(5.dp)
|
||||||
val PinBottomIconSize = Modifier.size(70.dp).padding(10.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 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)
|
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 SpacedBy2dp = Arrangement.spacedBy(Size2dp)
|
||||||
val SpacedBy5dp = Arrangement.spacedBy(Size5dp)
|
val SpacedBy5dp = Arrangement.spacedBy(Size5dp)
|
||||||
val SpacedBy10dp = Arrangement.spacedBy(Size10dp)
|
val SpacedBy10dp = Arrangement.spacedBy(Size10dp)
|
||||||
|
val SpacedBy55dp = Arrangement.spacedBy(Size55dp)
|
||||||
|
|
||||||
val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp)
|
val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user