mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(audio): picture-in-picture for long audio in posts
Audio cards for tracks of ten minutes or more (by the player, else the imeta duration) show a picture-in-picture button; the overlay's PiP button does the same for any audio. Both pause the inline player and hand the same pooled player to the PiP window, which resumes from there. For audio, the PiP window is now square and draws the cover (imeta image, else the generated one), the waveform as progress and the time, instead of an empty video surface. It starts unmuted: the feed's mute default is for silently autoplaying video. MediaItemData gains an isAudio flag, and it and the hash survive the PiP intent. MediaUrlVideo carries the imeta duration, so the card shows the length and offers PiP before the player has probed the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE
This commit is contained in:
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import com.vitorpamplona.amethyst.commons.audio.player.AudioPlaybackUi
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** The player's state as the card draws it: event-driven, plus a position poll while it plays. */
|
||||
@Composable
|
||||
fun rememberAudioPlaybackUi(player: Player): State<AudioPlaybackUi> {
|
||||
val state = remember(player) { mutableStateOf(player.toPlaybackUi()) }
|
||||
DisposableEffect(player) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onEvents(
|
||||
player: Player,
|
||||
events: Player.Events,
|
||||
) {
|
||||
state.value = player.toPlaybackUi()
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
state.value = player.toPlaybackUi()
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
|
||||
val isPlaying = state.value.isPlaying
|
||||
LaunchedEffect(player, isPlaying) {
|
||||
while (isPlaying) {
|
||||
delay(POSITION_POLL_MS)
|
||||
state.value = player.toPlaybackUi()
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun Player.toPlaybackUi() =
|
||||
AudioPlaybackUi(
|
||||
// Matches what the button should offer: pause while playing or about to (buffering with
|
||||
// playWhenReady), play when paused, ended or idle.
|
||||
isPlaying = !Util.shouldShowPlayButton(this),
|
||||
positionMs = currentPosition.coerceAtLeast(0),
|
||||
durationMs = duration.takeIf { it != C.TIME_UNSET && it > 0 },
|
||||
)
|
||||
|
||||
private const val POSITION_POLL_MS = 250L
|
||||
+3
@@ -54,6 +54,9 @@ data class MediaItemData(
|
||||
// NIP-71 `text-track` captions to side-load. A data class is the cache key, so this has to be
|
||||
// a value that compares by content — ImmutableList keeps that true and keeps the key @Stable.
|
||||
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
|
||||
// Set by the in-post audio cards: picture-in-picture then draws the audio view (cover, waveform,
|
||||
// time) in a square window, unmuted, instead of a video surface with no picture.
|
||||
val isAudio: Boolean = false,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
||||
@@ -62,6 +62,8 @@ class IntentExtras {
|
||||
keepPlaying = intent.getBoolean("keepPlaying", true),
|
||||
waveformData = intent.getFloatArray("wavefrontData")?.toList()?.let { WaveformData(it) },
|
||||
isLiveStream = intent.getBoolean("isLiveStream", false),
|
||||
hash = intent.getString("hash"),
|
||||
isAudio = intent.getBoolean("isAudio", false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +83,8 @@ class IntentExtras {
|
||||
putBoolean("keepPlaying", data.keepPlaying)
|
||||
data.waveformData?.let { putFloatArray("wavefrontData", it.wave.toFloatArray()) }
|
||||
putBoolean("isLiveStream", data.isLiveStream)
|
||||
data.hash?.let { putString("hash", it) }
|
||||
putBoolean("isAudio", data.isAudio)
|
||||
|
||||
bounds?.let { putInt("boundLeft", it.left) }
|
||||
bounds?.let { putInt("boundRight", it.right) }
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.pip
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.audio.mediaFormatLabel
|
||||
import com.vitorpamplona.amethyst.commons.audio.player.AudioCardInfo
|
||||
import com.vitorpamplona.amethyst.commons.audio.player.AudioPipContent
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.rememberAudioPlaybackUi
|
||||
|
||||
/**
|
||||
* The picture-in-picture window for audio sent there from an in-post audio card: the cover (artwork,
|
||||
* else the one generated from the file hash), the waveform as progress, and the time. There is no
|
||||
* picture to show, so unlike [RenderPipVideo] it draws no video surface and doesn't hold the screen on —
|
||||
* audio keeps playing with the screen off.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderPipAudio(
|
||||
controller: MediaControllerState,
|
||||
mediaItemData: MediaItemData,
|
||||
) {
|
||||
val info =
|
||||
remember(mediaItemData) {
|
||||
AudioCardInfo(
|
||||
title = null,
|
||||
artist = mediaItemData.authorName,
|
||||
format = mediaFormatLabel(mediaItemData.mimeType, mediaItemData.videoUri),
|
||||
sizeBytes = null,
|
||||
seed = mediaItemData.hash ?: mediaItemData.videoUri,
|
||||
waveform = mediaItemData.waveformData?.wave,
|
||||
artworkUrl = mediaItemData.artworkUri,
|
||||
)
|
||||
}
|
||||
val playback by rememberAudioPlaybackUi(controller.controller)
|
||||
AudioPipContent(info, playback)
|
||||
}
|
||||
+9
-3
@@ -46,8 +46,10 @@ class PipVideoActivity : ComponentActivity() {
|
||||
setContent {
|
||||
val videoData by rememberVideoDataFromIntents()
|
||||
videoData?.let { mediaItemData ->
|
||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
||||
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
|
||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes.
|
||||
// Audio is never started muted: the feed's mute default is about silent autoplaying
|
||||
// video, and an audio track somebody just sent to PiP is meant to be heard.
|
||||
val muted = remember(mediaItemData) { !mediaItemData.isAudio && DEFAULT_MUTED_SETTING.value }
|
||||
|
||||
GetMediaItem(mediaItemData) { mediaItem ->
|
||||
GetVideoController(
|
||||
@@ -60,7 +62,11 @@ class PipVideoActivity : ComponentActivity() {
|
||||
RegisterBackgroundMedia(controllerState)
|
||||
RegisterControllerReceiver(controllerState)
|
||||
WatchControllerForActions(mediaItemData, controllerState)
|
||||
RenderPipVideo(controllerState, mediaItemData.waveformData)
|
||||
if (mediaItemData.isAudio) {
|
||||
RenderPipAudio(controllerState, mediaItemData)
|
||||
} else {
|
||||
RenderPipVideo(controllerState, mediaItemData.waveformData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-72
@@ -20,13 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Rect
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -36,16 +37,19 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import com.vitorpamplona.amethyst.commons.audio.PlayableLayout
|
||||
import com.vitorpamplona.amethyst.commons.audio.WaveformData
|
||||
import com.vitorpamplona.amethyst.commons.audio.mediaFormatLabel
|
||||
import com.vitorpamplona.amethyst.commons.audio.player.AudioCardInfo
|
||||
import com.vitorpamplona.amethyst.commons.audio.player.AudioPlaybackUi
|
||||
import com.vitorpamplona.amethyst.commons.audio.player.PlayableMediaCard
|
||||
import com.vitorpamplona.amethyst.commons.audio.syntheticWaveformFor
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled
|
||||
import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled_no_format
|
||||
@@ -53,13 +57,15 @@ import com.vitorpamplona.amethyst.commons.resources.playable_media_untitled
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.rememberAudioPlaybackUi
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderTopButtonsForVoice
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* What the player found when it probed a URL: true when every track is audio, false when there is a
|
||||
@@ -79,6 +85,9 @@ object PlayableMediaProbeCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio at least this long gets a picture-in-picture button on its card: it will outlast the post on screen. */
|
||||
private const val LONG_AUDIO_MS = 10 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* A playable file inside a post that is (or may be) audio: the shared [PlayableMediaCard] for [layout],
|
||||
* driven by the same pooled player as every other inline media.
|
||||
@@ -88,6 +97,9 @@ object PlayableMediaProbeCache {
|
||||
* fetched until play is pressed. Once the player knows the tracks, [onProbed] reports whether they are
|
||||
* all audio, which is how an undecided file finds its real card and how a mislabelled video leaves for
|
||||
* the video player.
|
||||
*
|
||||
* Known audio that runs [LONG_AUDIO_MS] or more (by the player, else the imeta `duration`) shows a
|
||||
* picture-in-picture button, so a podcast can keep playing in a floating window while the feed scrolls.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayableAudioView(
|
||||
@@ -96,6 +108,7 @@ fun PlayableAudioView(
|
||||
onProbed: (audioOnly: Boolean) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val authorName =
|
||||
remember(content.authorName, content.authorPubKey) {
|
||||
content.authorName ?: content.authorPubKey?.let { accountViewModel.getUserIfExists(it)?.toBestDisplayName() }
|
||||
@@ -111,6 +124,7 @@ fun PlayableAudioView(
|
||||
seed = content.hash ?: content.url,
|
||||
waveform = content.waveform,
|
||||
artworkUrl = content.artworkUri,
|
||||
declaredDurationMs = content.durationSeconds?.let { (it * 1000).toLong() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,33 +136,56 @@ fun PlayableAudioView(
|
||||
else -> stringRes(Res.string.audio_card_untitled_no_format)
|
||||
}
|
||||
|
||||
val mediaData =
|
||||
remember(content, mediaTitle, authorName) {
|
||||
MediaItemData(
|
||||
videoUri = content.url,
|
||||
authorName = authorName,
|
||||
title = mediaTitle,
|
||||
artworkUri = content.artworkUri,
|
||||
callbackUri = content.uri,
|
||||
mimeType = content.mimeType,
|
||||
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(content.url),
|
||||
keepPlaying = false,
|
||||
isLiveStream = content.isLiveStream,
|
||||
hash = content.hash,
|
||||
)
|
||||
}
|
||||
|
||||
var load by remember(content.url) { mutableStateOf(accountViewModel.settings.startVideoPlayback()) }
|
||||
var playWhenConnected by remember(content.url) { mutableStateOf(false) }
|
||||
var active by remember(content.url) { mutableStateOf<Pair<MediaControllerState, LoadedMediaItem>?>(null) }
|
||||
var active by remember(content.url) { mutableStateOf<MediaControllerState?>(null) }
|
||||
val controllerVisible = remember(content.url) { mutableStateOf(false) }
|
||||
// Where picture-in-picture animates from.
|
||||
val bounds = remember(content.url) { arrayOf<Rect?>(null) }
|
||||
val pipSupported = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) }
|
||||
|
||||
// Hands playback to the PiP window: it attaches to the same pooled player (same URL), so pausing
|
||||
// here first keeps the two from playing over each other, and it resumes from this position.
|
||||
val startPictureInPicture = {
|
||||
active?.controller?.pause()
|
||||
PipVideoActivity.callIn(
|
||||
mediaData.copy(
|
||||
// Square window, and the audio view instead of an empty video surface.
|
||||
aspectRatio = 1f,
|
||||
isAudio = true,
|
||||
waveformData = WaveformData(info.waveform ?: syntheticWaveformFor(info.seed).wave),
|
||||
),
|
||||
bounds[0],
|
||||
context.getActivity(),
|
||||
)
|
||||
}
|
||||
|
||||
if (load) {
|
||||
val proxyPort = remember(content.url) { accountViewModel.httpClientBuilder.proxyPortForVideo(content.url) }
|
||||
GetMediaItem(
|
||||
videoUri = content.url,
|
||||
title = mediaTitle,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = authorName,
|
||||
callbackUri = content.uri,
|
||||
mimeType = content.mimeType,
|
||||
proxyPort = proxyPort,
|
||||
keepPlaying = false,
|
||||
isLiveStream = content.isLiveStream,
|
||||
hash = content.hash,
|
||||
) { mediaItem ->
|
||||
GetMediaItem(mediaData) { mediaItem ->
|
||||
GetVideoController(mediaItem = mediaItem, muted = false) { controller ->
|
||||
PauseControllerWhenInBackground(controller)
|
||||
ReportProbe(controller.controller, onProbed)
|
||||
|
||||
DisposableEffect(controller, mediaItem) {
|
||||
active = controller to mediaItem
|
||||
DisposableEffect(controller) {
|
||||
active = controller
|
||||
onDispose {
|
||||
if (active?.first === controller) active = null
|
||||
if (active === controller) active = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +200,17 @@ fun PlayableAudioView(
|
||||
}
|
||||
}
|
||||
|
||||
val trackBounds =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
val b = coordinates.boundsInWindow()
|
||||
bounds[0] = Rect(b.left.toInt(), b.top.toInt(), b.right.toInt(), b.bottom.toInt())
|
||||
}
|
||||
|
||||
Box {
|
||||
val current = active
|
||||
if (current != null) {
|
||||
val (controller, mediaItem) = current
|
||||
val controller = active
|
||||
if (controller != null) {
|
||||
val playback by rememberAudioPlaybackUi(controller.controller)
|
||||
val totalMs = playback.durationMs ?: info.declaredDurationMs
|
||||
PlayableMediaCard(
|
||||
layout = layout,
|
||||
info = info,
|
||||
@@ -175,23 +218,25 @@ fun PlayableAudioView(
|
||||
onPlayPause = { playOrPause(controller.controller) },
|
||||
onSeek = { fraction -> playback.durationMs?.let { controller.controller.seekTo((it * fraction).toLong()) } },
|
||||
onClick = { controllerVisible.value = !controllerVisible.value },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
// Where picture-in-picture animates from.
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
controller.visibility.setBounds(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
|
||||
},
|
||||
onPictureInPicture = startPictureInPicture.takeIf { pipSupported && layout != PlayableLayout.UNDECIDED && totalMs != null && totalMs >= LONG_AUDIO_MS },
|
||||
modifier = trackBounds,
|
||||
overlay = {
|
||||
RenderTopButtonsForVoice(
|
||||
mediaData = mediaItem.src,
|
||||
controllerState = controller,
|
||||
mediaData = mediaData,
|
||||
controllerVisible = controllerVisible,
|
||||
startingMuteState = controller.controller.volume < 0.001,
|
||||
onMuteClick = { mute ->
|
||||
DEFAULT_MUTED_SETTING.value = mute
|
||||
controller.controller.volume = if (mute) 0f else 1f
|
||||
},
|
||||
onPictureInPictureClick = startPictureInPicture,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
val totalMs = info.declaredDurationMs
|
||||
PlayableMediaCard(
|
||||
layout = layout,
|
||||
info = info,
|
||||
@@ -201,6 +246,8 @@ fun PlayableAudioView(
|
||||
load = true
|
||||
},
|
||||
onSeek = {},
|
||||
onPictureInPicture = startPictureInPicture.takeIf { pipSupported && layout != PlayableLayout.UNDECIDED && totalMs != null && totalMs >= LONG_AUDIO_MS },
|
||||
modifier = trackBounds,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -237,44 +284,3 @@ private fun ReportProbe(
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The player's state as the card draws it: event-driven, plus a position poll while it plays. */
|
||||
@Composable
|
||||
private fun rememberAudioPlaybackUi(player: Player): State<AudioPlaybackUi> {
|
||||
val state = remember(player) { mutableStateOf(player.toPlaybackUi()) }
|
||||
DisposableEffect(player) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onEvents(
|
||||
player: Player,
|
||||
events: Player.Events,
|
||||
) {
|
||||
state.value = player.toPlaybackUi()
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
state.value = player.toPlaybackUi()
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
|
||||
val isPlaying = state.value.isPlaying
|
||||
LaunchedEffect(player, isPlaying) {
|
||||
while (isPlaying) {
|
||||
delay(POSITION_POLL_MS)
|
||||
state.value = player.toPlaybackUi()
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun Player.toPlaybackUi() =
|
||||
AudioPlaybackUi(
|
||||
// Matches what the button should offer: pause while playing or about to (buffering with
|
||||
// playWhenReady), play when paused, ended or idle.
|
||||
isPlaying = !Util.shouldShowPlayButton(this),
|
||||
positionMs = currentPosition.coerceAtLeast(0),
|
||||
durationMs = duration.takeIf { it != C.TIME_UNSET && it > 0 },
|
||||
)
|
||||
|
||||
private const val POSITION_POLL_MS = 250L
|
||||
|
||||
+3
@@ -122,6 +122,9 @@ open class MediaUrlVideo(
|
||||
val sizeBytes: Long? = null,
|
||||
// The imeta `waveform` (NIP-A0 amplitudes, not normalised): an audio card draws it as its seek bar.
|
||||
val waveform: List<Float>? = null,
|
||||
// The imeta `duration`, in seconds: an audio card shows it, and offers picture-in-picture for long
|
||||
// audio, before the player has probed the file.
|
||||
val durationSeconds: Double? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey)
|
||||
|
||||
@Immutable
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -99,6 +100,7 @@ class RichTextParser {
|
||||
authorPubKey = authorPubKey,
|
||||
sizeBytes = (frags[SizeTag.TAG_NAME] ?: tags[SizeTag.TAG_NAME]?.firstOrNull())?.toLongOrNull(),
|
||||
waveform = (frags[WaveformTag.TAG_NAME] ?: tags[WaveformTag.TAG_NAME]?.firstOrNull())?.let { WaveformTag.parseWave(it) },
|
||||
durationSeconds = (frags[DurationTag.TAG_NAME] ?: tags[DurationTag.TAG_NAME]?.firstOrNull())?.toDoubleOrNull()?.takeIf { it > 0 },
|
||||
)
|
||||
} else if (kind == MediaContentKind.PDF) {
|
||||
MediaUrlPdf(
|
||||
|
||||
+8
@@ -49,6 +49,14 @@ class RichTextParserAudioImetaTest {
|
||||
assertEquals("audio/mpeg", media.mimeType)
|
||||
assertEquals(4_992_768L, media.sizeBytes)
|
||||
assertNull(media.waveform)
|
||||
assertNull(media.durationSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durationReachesTheMedia() {
|
||||
val media = parse("m audio/mpeg", "duration 3725.4")
|
||||
assertIs<MediaUrlVideo>(media)
|
||||
assertEquals(3725.4, media.durationSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+38
-1
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -86,7 +87,19 @@ object AudioPlayerSamples {
|
||||
/** An HLS playlist: audio only or video, nothing tells until the player probes it. */
|
||||
val stream = AudioCardInfo(title = null, artist = AUTHOR_NAME, format = "HLS", sizeBytes = null, seed = HLS_URL)
|
||||
|
||||
/** A 1h12 podcast episode: long enough that the card offers picture-in-picture. */
|
||||
val podcast =
|
||||
AudioCardInfo(
|
||||
title = null,
|
||||
artist = AUTHOR_NAME,
|
||||
format = "MP3",
|
||||
sizeBytes = 69_206_016,
|
||||
seed = "a7d93c0e5b2f4816",
|
||||
declaredDurationMs = 4_325_000,
|
||||
)
|
||||
|
||||
val playing = AudioPlaybackUi(isPlaying = true, positionMs = 72_000, durationMs = 208_000)
|
||||
val podcastPlaying = AudioPlaybackUi(isPlaying = true, positionMs = 1_534_000, durationMs = 4_325_000)
|
||||
val pausedEarly = AudioPlaybackUi(isPlaying = false, positionMs = 9_000, durationMs = 41_000)
|
||||
}
|
||||
|
||||
@@ -139,7 +152,8 @@ private fun Card(
|
||||
layout: PlayableLayout,
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi = AudioPlaybackUi.Idle,
|
||||
) = PlayableMediaCard(layout, info, playback, onPlayPause = {}, onSeek = {})
|
||||
pip: Boolean = false,
|
||||
) = PlayableMediaCard(layout, info, playback, onPlayPause = {}, onSeek = {}, onPictureInPicture = if (pip) ({}) else null)
|
||||
|
||||
@Preview(widthDp = 820, heightDp = 760)
|
||||
@Composable
|
||||
@@ -175,3 +189,26 @@ fun AudioUndecidedPreview() =
|
||||
MockAudioPost(text = "Live set tonight, tune in") { Card(playableLayout(null, AudioPlayerSamples.HLS_URL, hasArtwork = false), AudioPlayerSamples.stream) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 820, heightDp = 1000)
|
||||
@Composable
|
||||
fun AudioPictureInPicturePreview() =
|
||||
AudioPreviewFrame {
|
||||
val waveform = playableLayout("audio/mpeg", AudioPlayerSamples.MP3_URL, hasArtwork = false)
|
||||
val cover = playableLayout("audio/mpeg", AudioPlayerSamples.MP3_URL, hasArtwork = true)
|
||||
Column {
|
||||
Caption("Long audio offers picture-in-picture (before load: imeta duration)")
|
||||
MockAudioPost(text = "New episode is out!") { Card(waveform, AudioPlayerSamples.podcast, pip = true) }
|
||||
MockAudioPost(text = "New episode is out!") { Card(cover, AudioPlayerSamples.podcast, AudioPlayerSamples.podcastPlaying, pip = true) }
|
||||
Caption("The PiP window")
|
||||
Row(Modifier.padding(12.dp)) {
|
||||
Box(Modifier.size(180.dp).clip(RoundedCornerShape(12.dp))) {
|
||||
AudioPipContent(AudioPlayerSamples.podcast, AudioPlayerSamples.podcastPlaying)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Box(Modifier.size(180.dp).clip(RoundedCornerShape(12.dp))) {
|
||||
AudioPipContent(AudioPlayerSamples.withArtwork, AudioPlayerSamples.playing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+104
-4
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled
|
||||
import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled_no_format
|
||||
import com.vitorpamplona.amethyst.commons.resources.pause
|
||||
import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
|
||||
import com.vitorpamplona.amethyst.commons.resources.play
|
||||
import com.vitorpamplona.amethyst.commons.resources.playable_media_untitled
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
@@ -111,6 +112,8 @@ class AudioCardInfo(
|
||||
val waveform: List<Float>? = null,
|
||||
/** The imeta `image`: the artwork [AudioCoverCard] shows. */
|
||||
val artworkUrl: String? = null,
|
||||
/** The imeta `duration`: shown until the player knows the real one. */
|
||||
val declaredDurationMs: Long? = null,
|
||||
) {
|
||||
/** "MP3 · 4.8 MB" — known from the imeta alone. */
|
||||
val fileFacts: String
|
||||
@@ -142,11 +145,12 @@ fun PlayableMediaCard(
|
||||
onSeek: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onPictureInPicture: (() -> Unit)? = null,
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
when (layout) {
|
||||
PlayableLayout.AUDIO_WAVEFORM -> AudioWaveformCard(info, playback, onPlayPause, onSeek, modifier, onClick, overlay)
|
||||
PlayableLayout.AUDIO_COVER -> AudioCoverCard(info, playback, onPlayPause, onSeek, modifier, onClick, overlay)
|
||||
PlayableLayout.AUDIO_WAVEFORM -> AudioWaveformCard(info, playback, onPlayPause, onSeek, modifier, onClick, onPictureInPicture, overlay)
|
||||
PlayableLayout.AUDIO_COVER -> AudioCoverCard(info, playback, onPlayPause, onSeek, modifier, onClick, onPictureInPicture, overlay)
|
||||
PlayableLayout.UNDECIDED -> UndecidedMediaCard(info, playback, onPlayPause, modifier, onClick, overlay)
|
||||
PlayableLayout.VIDEO -> Unit
|
||||
}
|
||||
@@ -164,6 +168,8 @@ fun AudioWaveformCard(
|
||||
onSeek: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
/** Non-null shows the picture-in-picture button: meant for long audio, where the post will scroll away. */
|
||||
onPictureInPicture: (() -> Unit)? = null,
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
val bars = rememberBars(info)
|
||||
@@ -192,7 +198,7 @@ fun AudioWaveformCard(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
if (playback.durationMs != null) formatClock(playback.positionMs) else audioTitle(info),
|
||||
if (playback.durationMs != null || info.declaredDurationMs != null) formatClock(playback.positionMs) else audioTitle(info),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
@@ -208,6 +214,10 @@ fun AudioWaveformCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onPictureInPicture != null) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
PictureInPictureButton(onPictureInPicture, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
overlay()
|
||||
}
|
||||
@@ -225,6 +235,8 @@ fun AudioCoverCard(
|
||||
onSeek: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
/** Non-null shows the picture-in-picture button: meant for long audio, where the post will scroll away. */
|
||||
onPictureInPicture: (() -> Unit)? = null,
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
val bars = rememberBars(info)
|
||||
@@ -299,10 +311,84 @@ fun AudioCoverCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onPictureInPicture != null) {
|
||||
PictureInPictureButton(
|
||||
onPictureInPicture,
|
||||
tint = Color.White,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.4f)),
|
||||
)
|
||||
}
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Picture-in-picture: what the small floating window shows for audio. Square, display-only (a PiP
|
||||
// window takes no touches; the system draws play/pause over it), the cover behind the waveform.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun AudioPipContent(
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bars = rememberBars(info)
|
||||
Box(modifier.fillMaxSize()) {
|
||||
GeneratedCover(info.seed, Modifier.fillMaxSize(), icon = null, record = info.artworkUrl == null)
|
||||
if (info.artworkUrl != null) {
|
||||
AsyncImage(
|
||||
model = info.artworkUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.6f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f)))),
|
||||
)
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(10.dp)) {
|
||||
Text(
|
||||
audioTitle(info),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
info.artist?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
WaveformBars(
|
||||
bars = bars,
|
||||
progress = playback.progress,
|
||||
played = Color.White,
|
||||
unplayed = Color.White.copy(alpha = 0.35f),
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp),
|
||||
barWidth = 2.dp,
|
||||
gap = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatClock(playback.positionMs), style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f))
|
||||
(playback.durationMs ?: info.declaredDurationMs)?.let {
|
||||
Text(formatClock(it), style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Audio or video, can't tell yet: commits to neither shape (no waveform, no picture box) and gives way
|
||||
// to the waveform, the cover or the video player once the probe answers.
|
||||
@@ -393,7 +479,7 @@ private fun audioTitle(info: AudioCardInfo): String =
|
||||
private fun trailingFacts(
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi,
|
||||
): String = listOfNotNull(info.fileFacts.ifEmpty { null }, playback.durationMs?.let(::formatClock)).joinToString(" · ")
|
||||
): String = listOfNotNull(info.fileFacts.ifEmpty { null }, (playback.durationMs ?: info.declaredDurationMs)?.let(::formatClock)).joinToString(" · ")
|
||||
|
||||
private fun Modifier.clickableWithoutRipple(onClick: (() -> Unit)?): Modifier =
|
||||
if (onClick == null) {
|
||||
@@ -402,6 +488,20 @@ private fun Modifier.clickableWithoutRipple(onClick: (() -> Unit)?): Modifier =
|
||||
this.then(Modifier.clickable(interactionSource = null, indication = null, onClick = onClick))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PictureInPictureButton(
|
||||
onClick: () -> Unit,
|
||||
tint: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.size(36.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(MaterialSymbols.PictureInPicture, stringRes(Res.string.picture_in_picture), Modifier.size(22.dp), tint = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
|
||||
+2
@@ -72,6 +72,8 @@ class PlayableMediaCardsRenderTest {
|
||||
|
||||
@Test fun undecided() = render("03-undecided-track-card", 820, 360) { AudioUndecidedPreview() }
|
||||
|
||||
@Test fun pictureInPicture() = render("04-picture-in-picture", 820, 1000) { AudioPictureInPicturePreview() }
|
||||
|
||||
private companion object {
|
||||
const val SETTLE_FRAMES = 12
|
||||
const val FRAME_MILLIS = 60L
|
||||
|
||||
Reference in New Issue
Block a user