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