YakBak in PictureInPicture mode

New audio player UI for YakBak
This commit is contained in:
Vitor Pamplona
2025-08-20 21:00:09 -04:00
parent 2f82233b25
commit a27acd9dc6
15 changed files with 261 additions and 51 deletions
@@ -67,10 +67,11 @@ fun GetVideoController(
scope.launch {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
PlaybackServiceClient.prepareController(
controllerId,
mediaItem.src.videoUri,
mediaItem.src.proxyPort,
context,
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
@@ -174,10 +175,11 @@ fun GetVideoController(
scope.launch(Dispatchers.Main) {
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
PlaybackServiceClient.prepareController(
controllerId,
mediaItem.src.videoUri,
mediaItem.src.proxyPort,
context,
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.composable
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
@@ -49,7 +50,6 @@ fun RenderVideoPlayer(
thumbData: VideoThumb?,
showControls: Boolean = true,
contentScale: ContentScale,
waveform: WaveformData? = null,
borderModifier: Modifier,
videoModifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
@@ -64,6 +64,13 @@ fun RenderVideoPlayer(
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we alrady know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
@@ -101,7 +108,7 @@ fun RenderVideoPlayer(
},
)
waveform?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
if (showControls) {
RenderControlButtons(
@@ -55,14 +55,16 @@ fun VideoViewInner(
val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value }
GetMediaItem(
videoUri,
title,
artworkUri,
authorName,
nostrUriCallback,
mimeType,
aspectRatio,
videoUri = videoUri,
title = title,
artworkUri = artworkUri,
authorName = authorName,
callbackUri = nostrUriCallback,
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = accountViewModel.proxyPortFor(videoUri),
keepPlaying = true,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -76,7 +78,6 @@ fun VideoViewInner(
thumbData = thumb,
showControls = showControls,
contentScale = contentScale,
waveform = waveform,
borderModifier = borderModifier,
videoModifier = videoModifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
val mediaItemCache = MediaItemCache()
@@ -37,6 +38,8 @@ fun GetMediaItem(
mimeType: String? = null,
aspectRatio: Float? = null,
proxyPort: Int? = null,
keepPlaying: Boolean = false,
waveformData: WaveformData? = null,
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val data =
@@ -50,6 +53,8 @@ fun GetMediaItem(
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = proxyPort,
keepPlaying = keepPlaying,
waveformData = waveformData,
)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
@Immutable
data class MediaItemData(
@@ -33,6 +34,8 @@ data class MediaItemData(
val mimeType: String? = null,
val aspectRatio: Float? = null,
val proxyPort: Int? = null,
val keepPlaying: Boolean = true,
val waveformData: WaveformData? = null,
)
@Immutable
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.R.attr.data
import android.graphics.Rect
import android.os.Bundle
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.quartz.utils.ensure
@@ -58,6 +60,8 @@ class IntentExtras {
mimeType = intent.getString("mimeType"),
aspectRatio = if (ratio > 0) ratio else null,
proxyPort = if (port > 0) port else null,
keepPlaying = intent.getBoolean("keepPlaying", true),
waveformData = intent.getFloatArray("wavefrontData")?.toList()?.let { WaveformData(it) },
)
}
@@ -74,6 +78,8 @@ class IntentExtras {
data.mimeType?.let { putString("mimeType", it) }
data.aspectRatio?.let { putFloat("aspectRatio", it) }
data.proxyPort?.let { putInt("proxyPort", it) }
data.keepPlaying.let { putBoolean("keepPlaying", it) }
data.waveformData?.let { putFloatArray("wavefrontData", it.wave.toFloatArray()) }
bounds?.let { putInt("boundLeft", it.left) }
bounds?.let { putInt("boundRight", it.right) }
@@ -24,6 +24,7 @@ import android.content.Context
import android.content.Intent
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -42,8 +43,11 @@ 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.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
@Composable
fun PipVideo() {
@@ -69,7 +73,7 @@ fun PipVideo() {
videoData?.let {
GetMediaItem(it) { mediaItem ->
GetVideoController(mediaItem, false) { controller ->
PipVideo(controller)
PipVideo(controller, it.waveformData)
}
}
}
@@ -77,7 +81,10 @@ fun PipVideo() {
@OptIn(UnstableApi::class)
@Composable
fun PipVideo(controller: MediaControllerState) {
fun PipVideo(
controller: MediaControllerState,
waveformData: WaveformData?,
) {
DisposableEffect(controller) {
BackgroundMedia.switchKeepPlaying(controller)
onDispose {
@@ -117,5 +124,9 @@ fun PipVideo(controller: MediaControllerState) {
}
},
)
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
waveformData?.let { Waveform(it, controller, Modifier) }
}
}
}
@@ -56,7 +56,7 @@ class SessionListener(
class MediaSessionPool(
val exoPlayerPool: ExoPlayerPool,
val okHttpClient: OkHttpClient,
val reset: (MediaSession) -> Unit,
val reset: (MediaSession, Boolean) -> Unit,
) {
val globalCallback = MediaSessionCallback(this)
var lastCleanup = TimeUtils.now()
@@ -65,7 +65,7 @@ class MediaSessionPool(
private val playingMap = mutableMapOf<String, SessionListener>()
private val cache =
object : LruCache<String, SessionListener>(SimultaneousPlaybackCalculator.max()) { // up to 10 videos in the screen at the same time
object : LruCache<String, SessionListener>(10) { // up to 10 videos in the screen at the same time
override fun entryRemoved(
evicted: Boolean,
key: String?,
@@ -87,6 +87,7 @@ class MediaSessionPool(
@OptIn(UnstableApi::class)
fun newSession(
id: String,
keepPlaying: Boolean,
context: Context,
): MediaSession {
val mediaSession =
@@ -107,7 +108,7 @@ class MediaSessionPool(
mediaSession.player.addListener(listener)
reset(mediaSession)
reset(mediaSession, keepPlaying)
cache.put(mediaSession.id, SessionListener(mediaSession, listener))
@@ -167,6 +168,7 @@ class MediaSessionPool(
fun getSession(
id: String,
keepPlaying: Boolean,
context: Context,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
@@ -174,7 +176,7 @@ class MediaSessionPool(
return existingSession.session
}
return newSession(id, context)
return newSession(id, keepPlaying, context)
}
fun playingContent() = playingMap.values
@@ -44,9 +44,9 @@ class PlaybackService : MediaSessionService() {
MediaSessionPool(
ExoPlayerPool(ExoPlayerBuilder(okHttp)),
okHttpClient = okHttp,
reset = { session ->
reset = { session, keepPlaying ->
(session.player as ExoPlayer).apply {
repeatMode = Player.REPEAT_MODE_ONE
repeatMode = if (keepPlaying) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
}
@@ -145,7 +145,8 @@ class PlaybackService : MediaSessionService() {
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
val id = controllerInfo.connectionHints.getString("id") ?: return null
val proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
val keepPlaying = controllerInfo.connectionHints.getBoolean("keepPlaying", true)
val manager = lazyPool(proxyPort)
return manager.getSession(id, applicationContext)
return manager.getSession(id, keepPlaying, applicationContext)
}
}
@@ -60,6 +60,7 @@ object PlaybackServiceClient {
mediaControllerState: MediaControllerState,
videoUri: String,
proxyPort: Int? = 0,
keepPlaying: Boolean = true,
context: Context,
onReady: (MediaControllerState) -> Unit,
) {
@@ -73,6 +74,7 @@ object PlaybackServiceClient {
// link the id with the client's id to make sure it can return the
// same session on background media.
putString("id", mediaControllerState.id)
putBoolean("keepPlaying", keepPlaying)
proxyPort?.let {
putInt("proxyPort", it)
}
@@ -798,7 +798,7 @@ private fun RenderNoteRow(
is VideoNormalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
is VideoShortEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
is BaseVoiceEvent -> RenderVoiceTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
is BaseVoiceEvent -> RenderVoiceTrack(baseNote, accountViewModel, nav)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
is CommunityPostApprovalEvent -> {
RenderPostApproval(
@@ -20,22 +20,56 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.PlayCircleOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.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.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia.isPlaying
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.Size75Modifier
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
@@ -43,23 +77,28 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
@Composable
fun RenderVoiceTrack(
note: Note,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? BaseVoiceEvent ?: return
VoiceHeader(noteEvent, note, contentScale, accountViewModel, nav)
VoiceHeader(noteEvent, note, accountViewModel, nav)
}
@Composable
fun VoiceHeader(
noteEvent: BaseVoiceEvent,
note: Note,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
val media =
remember(noteEvent) {
noteEvent.content.ifBlank { null } ?: noteEvent.iMetaTags().firstOrNull()?.url
}
if (media == null) return
val waveform =
remember(noteEvent) {
noteEvent
@@ -68,32 +107,40 @@ fun VoiceHeader(
?.waveform
?.let { WaveformData(it) }
}
val media =
remember(noteEvent) {
noteEvent.content.ifBlank { null } ?: noteEvent.iMetaTags().firstOrNull()?.url
}
if (media == null) return
val callbackUri = remember(note) { note.toNostrUri() }
Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
VideoView(
GetMediaItem(
videoUri = media,
mimeType = null,
waveform = waveform,
title = noteEvent.subject(),
artworkUri = null,
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
contentScale = contentScale,
accountViewModel = accountViewModel,
nostrUriCallback = note.toNostrUri(),
)
callbackUri = callbackUri,
mimeType = null,
aspectRatio = null,
proxyPort = accountViewModel.proxyPortFor(media),
keepPlaying = false,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = false,
) { controller ->
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = MaterialTheme.colorScheme.imageModifier,
accountViewModel = accountViewModel,
)
}
}
}
val callbackUri = remember(note) { note.toNostrUri() }
if (noteEvent.hasHashtags()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav)
@@ -101,3 +148,119 @@ fun VoiceHeader(
}
}
}
@Composable
@OptIn(UnstableApi::class)
fun RenderVoicePlayer(
mediaItem: LoadedMediaItem,
controllerState: MediaControllerState,
waveform: WaveformData? = null,
borderModifier: Modifier,
accountViewModel: AccountViewModel,
) {
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
Box(modifier = borderModifier) {
AndroidView(
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we alrady know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = true
hideController()
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
},
)
}
},
)
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
PlayPauseButton(controllerState)
waveform?.let { Waveform(it, controllerState, Modifier) }
}
RenderControlButtons(
mediaItem.src,
controllerState,
controllerVisible,
Modifier.align(Alignment.TopEnd),
accountViewModel,
)
}
}
@Composable
fun PlayPauseButton(controllerState: MediaControllerState) {
val controller = controllerState.controller
var isPlaying by remember(controller) {
mutableStateOf(controllerState.isPlaying())
}
if (controller != null) {
val view = LocalView.current
// Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = controller, key2 = view) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (view.keepScreenOn != playing) {
view.keepScreenOn = playing
}
isPlaying = playing
}
fun destroy() {
if (view.keepScreenOn) {
view.keepScreenOn = false
}
isPlaying = false
}
}
controller.addListener(listener)
onDispose {
controller.removeListener(listener)
listener.destroy()
}
}
// Play/Pause Button
IconButton(
onClick = {
if (controller.isPlaying) {
controller.pause()
} else {
controller.play()
}
},
modifier = Size75Modifier,
) {
Icon(
imageVector = if (isPlaying) Icons.Default.PauseCircleOutline else Icons.Default.PlayCircleOutline,
contentDescription = if (isPlaying) stringResource(R.string.pause) else stringResource(R.string.play),
modifier = Size50Modifier,
tint = Color.White,
)
}
}
}
@@ -552,7 +552,7 @@ private fun FullBleedNoteCompose(
} else if (noteEvent is PictureEvent) {
PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav)
} else if (noteEvent is BaseVoiceEvent) {
VoiceHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav)
VoiceHeader(noteEvent, baseNote, accountViewModel, nav)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
@@ -165,6 +165,7 @@ val Size39Modifier = Modifier.size(39.dp)
val Size40Modifier = Modifier.size(40.dp)
val Size50Modifier = Modifier.size(50.dp)
val Size55Modifier = Modifier.size(55.dp)
val Size75Modifier = Modifier.size(75.dp)
val TinyBorders = Modifier.padding(2.dp)
val NoSoTinyBorders = Modifier.padding(start = 5.dp, end = 5.dp, top = 2.dp, bottom = 2.dp)
@@ -351,3 +352,7 @@ val SimpleImageBorder = Modifier.fillMaxSize().clip(QuoteBorder)
val SimpleHeaderImage = Modifier.fillMaxWidth().heightIn(max = 200.dp)
val BadgePictureModifier = Modifier.size(35.dp).clip(shape = CutCornerShape(20))
val MaxWidthPaddingTop5dp = Modifier.fillMaxWidth().padding(top = 5.dp)
val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.dp)
+2
View File
@@ -1229,4 +1229,6 @@
<string name="dont_translate_from">Don\'t Translate From</string>
<string name="dont_translate_from_description">Languages shown here will not be translated. Select a language to remove it and have it translated again.</string>
<string name="pause">Pause</string>
<string name="play">Play</string>
</resources>