From a27acd9dc602eac26f940c47cb5f2e87de2f5b2f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 20 Aug 2025 21:00:09 -0400 Subject: [PATCH] YakBak in PictureInPicture mode New audio player UI for YakBak --- .../playback/composable/GetVideoController.kt | 18 +- .../playback/composable/RenderVideoPlayer.kt | 11 +- .../playback/composable/VideoViewInner.kt | 17 +- .../composable/mediaitem/GetMediaItem.kt | 5 + .../composable/mediaitem/MediaItemData.kt | 3 + .../service/playback/pip/IntentExtras.kt | 6 + .../service/playback/pip/PipVideoView.kt | 15 +- .../playback/playerPool/MediaSessionPool.kt | 10 +- .../playback/service/PlaybackService.kt | 7 +- .../playback/service/PlaybackServiceClient.kt | 2 + .../amethyst/ui/note/NoteCompose.kt | 2 +- .../amethyst/ui/note/types/VoiceTrack.kt | 207 ++++++++++++++++-- .../loggedIn/threadview/ThreadFeedView.kt | 2 +- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 5 + amethyst/src/main/res/values/strings.xml | 2 + 15 files changed, 261 insertions(+), 51 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index f264bc9f53..e929a44c54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 1a9e667062..bf5eb3450f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 03aca3d3d9..0614fef0d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt index ab10d943c0..b88f35ef41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt index d4b664ff20..8b3166db2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt index a4ec77431f..5470724558 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 168e03897e..bc2df36be8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -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) } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 48bfad6b34..b299a3acc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -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() private val cache = - object : LruCache(SimultaneousPlaybackCalculator.max()) { // up to 10 videos in the screen at the same time + object : LruCache(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 0232d843c4..eb1c693f86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 1018b3813c..1bd5bf6dd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 1a99a0cb0b..b5c921998a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index a0ace2304a..e77a7734f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index d03940fff4..d4d89fd7c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 71568c03c8..affd665ebb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -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) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fb6d27b6fb..671fd077ff 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1229,4 +1229,6 @@ Don\'t Translate From Languages shown here will not be translated. Select a language to remove it and have it translated again. + Pause + Play