mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4300 from vitorpamplona/ccr-7900824a-sqkcsw
Add audio card UI for playable media in posts
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)
|
||||
}
|
||||
+8
-2
@@ -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,12 +62,16 @@ class PipVideoActivity : ComponentActivity() {
|
||||
RegisterBackgroundMedia(controllerState)
|
||||
RegisterControllerReceiver(controllerState)
|
||||
WatchControllerForActions(mediaItemData, controllerState)
|
||||
if (mediaItemData.isAudio) {
|
||||
RenderPipAudio(controllerState, mediaItemData)
|
||||
} else {
|
||||
RenderPipVideo(controllerState, mediaItemData.waveformData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* 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.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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
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
|
||||
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.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
|
||||
|
||||
/**
|
||||
* What the player found when it probed a URL: true when every track is audio, false when there is a
|
||||
* video track. Remembered per URL so a post scrolled back into view takes its probed shape at once
|
||||
* instead of starting from its declaration and reflowing again.
|
||||
*/
|
||||
object PlayableMediaProbeCache {
|
||||
private val cache = LruCache<String, Boolean>(500)
|
||||
|
||||
fun get(url: String): Boolean? = cache[url]
|
||||
|
||||
fun put(
|
||||
url: String,
|
||||
audioOnly: Boolean,
|
||||
) {
|
||||
cache.put(url, audioOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
* Unlike a feed video it never autoplays — audio playing muted is pointless and playing it aloud
|
||||
* unasked is worse — and it does not loop. With automatic media loading off (data saver), nothing is
|
||||
* 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(
|
||||
content: MediaUrlVideo,
|
||||
layout: PlayableLayout,
|
||||
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() }
|
||||
}
|
||||
|
||||
val info =
|
||||
remember(content, authorName) {
|
||||
AudioCardInfo(
|
||||
title = null,
|
||||
artist = authorName,
|
||||
format = mediaFormatLabel(content.mimeType, content.url),
|
||||
sizeBytes = content.sizeBytes,
|
||||
seed = content.hash ?: content.url,
|
||||
waveform = content.waveform,
|
||||
artworkUrl = content.artworkUri,
|
||||
declaredDurationMs = content.durationSeconds?.let { (it * 1000).toLong() },
|
||||
)
|
||||
}
|
||||
|
||||
// The name the system media notification shows.
|
||||
val mediaTitle =
|
||||
when {
|
||||
layout == PlayableLayout.UNDECIDED -> stringRes(Res.string.playable_media_untitled)
|
||||
info.format != null -> stringRes(Res.string.audio_card_untitled, info.format)
|
||||
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<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) {
|
||||
GetMediaItem(mediaData) { mediaItem ->
|
||||
GetVideoController(mediaItem = mediaItem, muted = false) { controller ->
|
||||
PauseControllerWhenInBackground(controller)
|
||||
ReportProbe(controller.controller, onProbed)
|
||||
|
||||
DisposableEffect(controller) {
|
||||
active = controller
|
||||
onDispose {
|
||||
if (active === controller) active = null
|
||||
}
|
||||
}
|
||||
|
||||
// Play was pressed before the controller connected.
|
||||
LaunchedEffect(controller) {
|
||||
if (playWhenConnected) {
|
||||
playWhenConnected = false
|
||||
controller.controller.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 controller = active
|
||||
if (controller != null) {
|
||||
val playback by rememberAudioPlaybackUi(controller.controller)
|
||||
val totalMs = playback.durationMs ?: info.declaredDurationMs
|
||||
PlayableMediaCard(
|
||||
layout = layout,
|
||||
info = info,
|
||||
playback = playback,
|
||||
onPlayPause = { playOrPause(controller.controller) },
|
||||
onSeek = { fraction -> playback.durationMs?.let { controller.controller.seekTo((it * fraction).toLong()) } },
|
||||
onClick = { controllerVisible.value = !controllerVisible.value },
|
||||
onPictureInPicture = startPictureInPicture.takeIf { pipSupported && layout != PlayableLayout.UNDECIDED && totalMs != null && totalMs >= LONG_AUDIO_MS },
|
||||
modifier = trackBounds,
|
||||
overlay = {
|
||||
RenderTopButtonsForVoice(
|
||||
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,
|
||||
playback = AudioPlaybackUi.Idle,
|
||||
onPlayPause = {
|
||||
playWhenConnected = true
|
||||
load = true
|
||||
},
|
||||
onSeek = {},
|
||||
onPictureInPicture = startPictureInPicture.takeIf { pipSupported && layout != PlayableLayout.UNDECIDED && totalMs != null && totalMs >= LONG_AUDIO_MS },
|
||||
modifier = trackBounds,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun playOrPause(player: Player) {
|
||||
// Handles the idle (re-prepare) and ended (restart) states, not just play/pause.
|
||||
Util.handlePlayPauseButtonAction(player)
|
||||
}
|
||||
|
||||
/** True when the tracks are all audio, false when one is video, null while the player hasn't found any. */
|
||||
internal fun Tracks.audioOnlyOrNull(): Boolean? {
|
||||
if (groups.isEmpty()) return null
|
||||
if (groups.any { it.type == C.TRACK_TYPE_VIDEO }) return false
|
||||
return if (groups.any { it.type == C.TRACK_TYPE_AUDIO }) true else null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportProbe(
|
||||
player: Player,
|
||||
onProbed: (Boolean) -> Unit,
|
||||
) {
|
||||
val currentOnProbed by rememberUpdatedState(onProbed)
|
||||
DisposableEffect(player) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
tracks.audioOnlyOrNull()?.let { currentOnProbed(it) }
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
player.currentTracks.audioOnlyOrNull()?.let { currentOnProbed(it) }
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
}
|
||||
+35
@@ -63,6 +63,8 @@ import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.audio.PlayableLayout
|
||||
import com.vitorpamplona.amethyst.commons.audio.playableLayout
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.MediaAspectRatioCache
|
||||
@@ -231,6 +233,38 @@ fun ZoomableContentView(
|
||||
}
|
||||
|
||||
is MediaUrlVideo -> {
|
||||
// Audio, or media that may be audio, gets an audio card: B (waveform) or C (cover) when the
|
||||
// declaration says audio, A while it can't tell. The player's probe, once in, overrides the
|
||||
// declaration — undecided files find their real card, mislabelled videos come back here.
|
||||
var probedAudioOnly by remember(content.url) { mutableStateOf(PlayableMediaProbeCache.get(content.url)) }
|
||||
val layout =
|
||||
remember(content.url, content.mimeType, content.artworkUri, content.isLiveStream, probedAudioOnly) {
|
||||
// A live stream is the one stream we know is video-first; it keeps the video player.
|
||||
if (content.isLiveStream) {
|
||||
PlayableLayout.VIDEO
|
||||
} else {
|
||||
playableLayout(content.mimeType, content.url, hasArtwork = content.artworkUri != null, probedAudioOnly = probedAudioOnly)
|
||||
}
|
||||
}
|
||||
|
||||
if (layout != PlayableLayout.VIDEO) {
|
||||
ContentWarningGate(
|
||||
isSensitive = content.contentWarning != null,
|
||||
reasons = setOfNotNull(content.contentWarning),
|
||||
preloadUrls = emptyList(),
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
PlayableAudioView(
|
||||
content = content,
|
||||
layout = layout,
|
||||
onProbed = { audioOnly ->
|
||||
PlayableMediaProbeCache.put(content.url, audioOnly)
|
||||
probedAudioOnly = audioOnly
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// The fallback classifies the URL string, so compute it once per content — for audio
|
||||
// the cache miss is permanent and this branch re-runs on every recomposition.
|
||||
val fallbackRatio =
|
||||
@@ -280,6 +314,7 @@ fun ZoomableContentView(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MediaLocalImage -> {
|
||||
if (content.isAnimatedMedia()) {
|
||||
|
||||
@@ -82,7 +82,18 @@ class LocalCacheSearchParityTest {
|
||||
|
||||
// LocalCache.consume refuses the main thread; a plain JVM test has no Looper, so the
|
||||
// check passes and the events land synchronously.
|
||||
//
|
||||
// The corpus is live relay data and carries the same long-form article posted by two
|
||||
// authors, which the anti-spam filter keeps out of the cache as a copy. This class tests
|
||||
// the filter path, not spam policy, so the whole corpus goes in; the flag is restored
|
||||
// because LocalCache is shared with every other test class in the JVM.
|
||||
val antiSpamWasActive = LocalCache.antiSpam.active
|
||||
LocalCache.antiSpam.active = false
|
||||
try {
|
||||
corpus.forEach { LocalCache.justConsumeMyOwnEvent(it) }
|
||||
} finally {
|
||||
LocalCache.antiSpam.active = antiSpamWasActive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.audio
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType
|
||||
|
||||
/** How a playable file inside a post is presented. */
|
||||
enum class PlayableLayout {
|
||||
/** Known video: the video player. */
|
||||
VIDEO,
|
||||
|
||||
/** Known audio that carries artwork: the cover, with the waveform scrubber over its foot. */
|
||||
AUDIO_COVER,
|
||||
|
||||
/** Known audio without artwork: the waveform scrubber — the file's own waveform, else a synthetic one. */
|
||||
AUDIO_WAVEFORM,
|
||||
|
||||
/** Could be either until the player probes it: the neutral track card, which commits to neither shape. */
|
||||
UNDECIDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the presentation for a playable file in a post.
|
||||
*
|
||||
* The declaration decides up front, so the card has its final shape before the player loads a byte. Once the
|
||||
* player has probed the file, [probedAudioOnly] (true when every track is audio) overrides the declaration:
|
||||
* that is what moves an [PlayableLayout.UNDECIDED] stream to its real shape, and what corrects a file whose
|
||||
* declaration was wrong.
|
||||
*/
|
||||
fun playableLayout(
|
||||
mimeType: String?,
|
||||
url: String,
|
||||
hasArtwork: Boolean,
|
||||
probedAudioOnly: Boolean? = null,
|
||||
): PlayableLayout {
|
||||
val isAudio = probedAudioOnly ?: declaredAsAudio(mimeType, url) ?: return PlayableLayout.UNDECIDED
|
||||
return when {
|
||||
!isAudio -> PlayableLayout.VIDEO
|
||||
hasArtwork -> PlayableLayout.AUDIO_COVER
|
||||
else -> PlayableLayout.AUDIO_WAVEFORM
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the declaration says audio, false when it says video, null when it can't tell.
|
||||
*
|
||||
* A declared `audio/` or `video/` MIME wins over the URL. HLS playlists are undecided whichever way they are
|
||||
* spelled — `audio/mpegurl` included — because a playlist carries either, and so are URLs whose extension
|
||||
* names neither, such as a bare Blossom hash.
|
||||
*/
|
||||
fun declaredAsAudio(
|
||||
mimeType: String?,
|
||||
url: String,
|
||||
): Boolean? {
|
||||
val mime = normalizeMimeType(mimeType)
|
||||
if (mime != null) {
|
||||
if (RichTextParser.isHlsMimeType(mime)) return null
|
||||
if (mime.startsWith("audio/")) return true
|
||||
if (mime.startsWith("video/")) return false
|
||||
}
|
||||
return when {
|
||||
RichTextParser.hasExtensionIn(url, HLS_EXTENSIONS) -> null
|
||||
RichTextParser.isAudioUrl(url) -> true
|
||||
RichTextParser.isVideoUrl(url) -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val HLS_EXTENSIONS = listOf("m3u8")
|
||||
|
||||
/**
|
||||
* A short container label for a card — "MP3", "FLAC", "HLS" — from the declared MIME type, else the URL's
|
||||
* extension. Null when neither names one.
|
||||
*/
|
||||
fun mediaFormatLabel(
|
||||
mimeType: String?,
|
||||
url: String,
|
||||
): String? {
|
||||
val mime = normalizeMimeType(mimeType)
|
||||
if (RichTextParser.isHlsMimeType(mime) || RichTextParser.hasExtensionIn(url, HLS_EXTENSIONS)) return "HLS"
|
||||
if (mime != null) {
|
||||
MIME_LABELS[mime.substringBefore(';').trim().lowercase()]?.let { return it }
|
||||
}
|
||||
return urlExtension(url)?.takeIf { RichTextParser.isAudioUrl(url) || RichTextParser.isVideoUrl(url) }?.uppercase()
|
||||
}
|
||||
|
||||
private val MIME_LABELS =
|
||||
mapOf(
|
||||
"audio/mpeg" to "MP3",
|
||||
"audio/mp3" to "MP3",
|
||||
"audio/mp4" to "M4A",
|
||||
"audio/x-m4a" to "M4A",
|
||||
"audio/aac" to "AAC",
|
||||
"audio/ogg" to "OGG",
|
||||
"audio/opus" to "OPUS",
|
||||
"audio/flac" to "FLAC",
|
||||
"audio/x-flac" to "FLAC",
|
||||
"audio/wav" to "WAV",
|
||||
"audio/x-wav" to "WAV",
|
||||
"audio/wave" to "WAV",
|
||||
"audio/webm" to "WEBM",
|
||||
"video/mp4" to "MP4",
|
||||
"video/webm" to "WEBM",
|
||||
"video/quicktime" to "MOV",
|
||||
)
|
||||
|
||||
/** The last path segment's extension, ignoring the query and fragment. */
|
||||
private fun urlExtension(url: String): String? {
|
||||
val path = url.substringBefore('#').substringBefore('?')
|
||||
val segment = path.substringAfterLast('/')
|
||||
val dot = segment.lastIndexOf('.')
|
||||
return if (dot in 0 until segment.length - 1) segment.substring(dot + 1) else null
|
||||
}
|
||||
+7
@@ -118,6 +118,13 @@ open class MediaUrlVideo(
|
||||
authorPubKey: String? = null,
|
||||
// NIP-71 `text-track` captions resolved for this video, for the player to side-load.
|
||||
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
|
||||
// The imeta `size`, in bytes: an audio card shows it before the player has loaded anything.
|
||||
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
|
||||
|
||||
+6
@@ -38,7 +38,10 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
|
||||
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
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -95,6 +98,9 @@ class RichTextParser {
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
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(
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.audio
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class PlayableLayoutTest {
|
||||
private val blossomMp3 =
|
||||
"https://npub17u5dneh8qjp43ecfxr6u5e9sjamsmxyuekrg2nlxrrk6nj9rsyrqywt4tp.blossom.band/" +
|
||||
"b21c6e2a4d38f2abac617ac6643aba919f271b37c1c539b5643c17753716c506.mp3"
|
||||
|
||||
@Test
|
||||
fun declaredAudioWithoutArtworkIsTheWaveform() {
|
||||
// The post that started this: imeta `m audio/mpeg`, no waveform, no image.
|
||||
assertEquals(PlayableLayout.AUDIO_WAVEFORM, playableLayout("audio/mpeg", blossomMp3, hasArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declaredAudioWithArtworkIsTheCover() {
|
||||
assertEquals(PlayableLayout.AUDIO_COVER, playableLayout("audio/mpeg", blossomMp3, hasArtwork = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioExtensionDecidesWithoutAMime() {
|
||||
assertEquals(PlayableLayout.AUDIO_WAVEFORM, playableLayout(null, "https://x.com/a.flac", hasArtwork = false))
|
||||
assertEquals(PlayableLayout.AUDIO_WAVEFORM, playableLayout(null, "https://x.com/a.Mp3?dl=1", hasArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declaredMimeBeatsTheExtension() {
|
||||
assertEquals(PlayableLayout.VIDEO, playableLayout("video/mp4", "https://x.com/a.mp3", hasArtwork = false))
|
||||
assertEquals(PlayableLayout.AUDIO_WAVEFORM, playableLayout("audio/mp4", "https://x.com/a.mp4", hasArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bareSubtypeMimeIsRepaired() {
|
||||
assertEquals(PlayableLayout.AUDIO_WAVEFORM, playableLayout("mp3", "https://x.com/abc", hasArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoIsVideo() {
|
||||
assertEquals(PlayableLayout.VIDEO, playableLayout("video/webm", "https://x.com/a", hasArtwork = false))
|
||||
assertEquals(PlayableLayout.VIDEO, playableLayout(null, "https://x.com/a.mov", hasArtwork = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistsAreUndecided() {
|
||||
assertEquals(PlayableLayout.UNDECIDED, playableLayout(null, "https://x.com/live.m3u8", hasArtwork = false))
|
||||
assertEquals(PlayableLayout.UNDECIDED, playableLayout("application/x-mpegurl", "https://x.com/live", hasArtwork = false))
|
||||
// Starts with `audio/` but is a playlist, which can carry video.
|
||||
assertEquals(PlayableLayout.UNDECIDED, playableLayout("audio/mpegurl", "https://x.com/live", hasArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMimeAndNoExtensionIsUndecided() {
|
||||
assertEquals(PlayableLayout.UNDECIDED, playableLayout(null, "https://blossom.example/b21c6e2a4d38f2ab", hasArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeOverridesTheDeclaration() {
|
||||
assertEquals(PlayableLayout.AUDIO_WAVEFORM, playableLayout(null, "https://x.com/live.m3u8", hasArtwork = false, probedAudioOnly = true))
|
||||
assertEquals(PlayableLayout.VIDEO, playableLayout(null, "https://x.com/live.m3u8", hasArtwork = false, probedAudioOnly = false))
|
||||
// An MPEG video mislabelled `audio/mpeg` moves to the video player once its tracks are known.
|
||||
assertEquals(PlayableLayout.VIDEO, playableLayout("audio/mpeg", "https://x.com/a.mpg", hasArtwork = false, probedAudioOnly = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatLabels() {
|
||||
assertEquals("MP3", mediaFormatLabel("audio/mpeg", blossomMp3))
|
||||
assertEquals("FLAC", mediaFormatLabel(null, "https://x.com/a.flac?dl=1"))
|
||||
assertEquals("M4A", mediaFormatLabel("audio/mp4", "https://x.com/abc"))
|
||||
assertEquals("HLS", mediaFormatLabel("audio/mpegurl", "https://x.com/live"))
|
||||
assertEquals("HLS", mediaFormatLabel(null, "https://x.com/live.m3u8"))
|
||||
assertEquals(null, mediaFormatLabel(null, "https://blossom.example/b21c6e2a4d38f2ab"))
|
||||
// A dot in the host is not an extension.
|
||||
assertEquals(null, mediaFormatLabel(null, "https://e.nostr.build/a_x_mp3"))
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.commons.richtext
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/** The imeta facts an audio card shows before the player loads: `size` and `waveform` reach [MediaUrlVideo]. */
|
||||
class RichTextParserAudioImetaTest {
|
||||
private val url =
|
||||
"https://npub17u5dneh8qjp43ecfxr6u5e9sjamsmxyuekrg2nlxrrk6nj9rsyrqywt4tp.blossom.band/" +
|
||||
"b21c6e2a4d38f2abac617ac6643aba919f271b37c1c539b5643c17753716c506.mp3"
|
||||
|
||||
private fun parse(vararg imeta: String) =
|
||||
RichTextParser()
|
||||
.parseText(
|
||||
"I can simply generate the esoteric beats I want to hear!\n$url",
|
||||
ImmutableListOfLists(arrayOf(arrayOf("imeta", "url $url", *imeta))),
|
||||
null,
|
||||
).mediaList
|
||||
.single()
|
||||
|
||||
@Test
|
||||
fun sizeAndMimeReachTheMedia() {
|
||||
// The post from the original report.
|
||||
val media = parse("x b21c6e2a4d38f2abac617ac6643aba919f271b37c1c539b5643c17753716c506", "size 4992768", "m audio/mpeg")
|
||||
assertIs<MediaUrlVideo>(media)
|
||||
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
|
||||
fun waveformReachesTheMedia() {
|
||||
val media = parse("m audio/mpeg", "waveform 0 7 35 100 42")
|
||||
assertIs<MediaUrlVideo>(media)
|
||||
assertEquals(listOf(0f, 7f, 35f, 100f, 42f), media.waveform)
|
||||
}
|
||||
}
|
||||
@@ -6362,6 +6362,9 @@
|
||||
<string name="browser_pill_download_risky">This file type can run code. Check that the name matches what you meant to get.</string>
|
||||
<string name="browser_pill_download_save">Save</string>
|
||||
<string name="browser_pill_download_from">From %1$s</string>
|
||||
<string name="audio_card_untitled">%1$s audio</string>
|
||||
<string name="audio_card_untitled_no_format">Audio</string>
|
||||
<string name="playable_media_untitled">Media</string>
|
||||
<string name="now_playing_settings">Now Playing</string>
|
||||
<string name="now_playing_search_keywords">music listening status song podcast spotify nip38 share playing</string>
|
||||
<string name="now_playing_description">Your status is public. It appears under your name and disappears when the track ends or you stop playing.</string>
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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.commons.audio.player
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.audio.PlayableLayout
|
||||
import com.vitorpamplona.amethyst.commons.audio.playableLayout
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
||||
|
||||
/** The post that prompted the audio cards (an MP3 with `m audio/mpeg`, no ID3 tags), plus the other cases. */
|
||||
object AudioPlayerSamples {
|
||||
const val AUTHOR_HEX = "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106"
|
||||
const val AUTHOR_NAME = "npub17u5d\u2026t4tp"
|
||||
const val POST_TEXT =
|
||||
"There aren't many nerdcore hip hop artists making music but I just realized that's no longer a problem: " +
|
||||
"I can simply generate the esoteric beats I want to hear!"
|
||||
private const val HASH = "b21c6e2a4d38f2abac617ac6643aba919f271b37c1c539b5643c17753716c506"
|
||||
const val MP3_URL = "https://npub17u5dneh8qjp43ecfxr6u5e9sjamsmxyuekrg2nlxrrk6nj9rsyrqywt4tp.blossom.band/$HASH.mp3"
|
||||
const val HLS_URL = "https://stream.example/live/index.m3u8"
|
||||
|
||||
/** Everything the imeta gives: format, size, hash. No title, no waveform. */
|
||||
val mp3 = AudioCardInfo(title = null, artist = AUTHOR_NAME, format = "MP3", sizeBytes = 4_992_768, seed = HASH)
|
||||
|
||||
/** The same kind of post with a NIP-A0 `waveform` (0..100 amplitudes). */
|
||||
val mp3WithWaveform =
|
||||
AudioCardInfo(
|
||||
title = null,
|
||||
artist = AUTHOR_NAME,
|
||||
format = "MP3",
|
||||
sizeBytes = 1_204_224,
|
||||
seed = "5e0f",
|
||||
waveform = listOf(4f, 9f, 22f, 41f, 38f, 12f, 6f, 30f, 72f, 88f, 64f, 20f, 8f, 15f, 46f, 93f, 100f, 77f, 35f, 10f, 5f, 18f, 52f, 61f, 28f, 9f, 3f, 25f, 58f, 80f, 66f, 31f, 12f, 7f, 19f, 44f, 70f, 49f, 16f, 4f),
|
||||
)
|
||||
|
||||
/** A named track with artwork (the cover here is the generated fallback; the imeta `image` would load over it). */
|
||||
val withArtwork =
|
||||
AudioCardInfo(
|
||||
title = "Segfault in the Cipher",
|
||||
artist = "Esoteric Beats Vol. 1",
|
||||
format = "MP3",
|
||||
sizeBytes = 4_992_768,
|
||||
seed = "3fa1c27be0d94e1a6c55b8f02d7e4a9c1b6f8e03d2a7c5b941e60f8d3c2b1a07",
|
||||
)
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/** Dark and light side by side, each filling its half, so screenshots have no transparent gaps. */
|
||||
@Composable
|
||||
private fun AudioPreviewFrame(content: @Composable () -> Unit) {
|
||||
ThemeComparisonRow {
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A stripped-down note: avatar, name, the post text, then the audio card where the media goes. */
|
||||
@Composable
|
||||
fun MockAudioPost(
|
||||
text: String = AudioPlayerSamples.POST_TEXT,
|
||||
media: @Composable () -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Image(
|
||||
imageVector = CachedRobohash.get(AudioPlayerSamples.AUTHOR_HEX, MaterialTheme.colorScheme.isLight),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(AudioPlayerSamples.AUTHOR_NAME, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
Text(" · 2h", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
media()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Caption(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Card(
|
||||
layout: PlayableLayout,
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi = AudioPlaybackUi.Idle,
|
||||
pip: Boolean = false,
|
||||
) = PlayableMediaCard(layout, info, playback, onPlayPause = {}, onSeek = {}, onPictureInPicture = if (pip) ({}) else null)
|
||||
|
||||
@Preview(widthDp = 820, heightDp = 760)
|
||||
@Composable
|
||||
fun AudioKnownAudioPreview() =
|
||||
AudioPreviewFrame {
|
||||
val layout = playableLayout("audio/mpeg", AudioPlayerSamples.MP3_URL, hasArtwork = false)
|
||||
Column {
|
||||
Caption("Known audio, no waveform tag: synthetic waveform, before load")
|
||||
MockAudioPost { Card(layout, AudioPlayerSamples.mp3) }
|
||||
Caption("Playing")
|
||||
MockAudioPost { Card(layout, AudioPlayerSamples.mp3, AudioPlayerSamples.playing) }
|
||||
Caption("With a waveform tag")
|
||||
MockAudioPost(text = "Quick voice memo about the relay outage") { Card(layout, AudioPlayerSamples.mp3WithWaveform, AudioPlayerSamples.pausedEarly) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 820, heightDp = 620)
|
||||
@Composable
|
||||
fun AudioWithArtworkPreview() =
|
||||
AudioPreviewFrame {
|
||||
Column {
|
||||
Caption("Known audio with artwork: the cover with the same scrubber")
|
||||
MockAudioPost { Card(playableLayout("audio/mpeg", AudioPlayerSamples.MP3_URL, hasArtwork = true), AudioPlayerSamples.withArtwork, AudioPlayerSamples.playing) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 820, heightDp = 360)
|
||||
@Composable
|
||||
fun AudioUndecidedPreview() =
|
||||
AudioPreviewFrame {
|
||||
Column {
|
||||
Caption("Audio or video? (HLS playlist): neutral card until the player probes it")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
/*
|
||||
* 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.commons.audio.player
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.audio.PlayableLayout
|
||||
import com.vitorpamplona.amethyst.commons.audio.syntheticWaveformFor
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
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
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/*
|
||||
* The cards a playable file inside a post renders as, instead of a video player with no picture.
|
||||
* Which one is [com.vitorpamplona.amethyst.commons.audio.playableLayout]'s call:
|
||||
* - known audio, no artwork -> [AudioWaveformCard]: the waveform is the seek bar (the file's own
|
||||
* waveform, else the synthetic one every other audio renderer draws);
|
||||
* - known audio with artwork -> [AudioCoverCard]: the artwork with the same scrubber over its foot;
|
||||
* - audio or video, can't tell -> [UndecidedMediaCard], until the player's probe settles it.
|
||||
*
|
||||
* Stateless: the platform owns the player and feeds [AudioPlaybackUi] in, taking play/pause and seeks
|
||||
* back out. Everything in [AudioCardInfo] comes from the imeta, so each card has its final shape before
|
||||
* the player has loaded a byte.
|
||||
*/
|
||||
|
||||
/** What a card shows about the file. All of it is known from the event, before the player loads anything. */
|
||||
@Immutable
|
||||
class AudioCardInfo(
|
||||
/** The track's name, when something names it. Null shows "MP3 audio" (or the like). */
|
||||
val title: String?,
|
||||
/** Usually the post's author. */
|
||||
val artist: String?,
|
||||
/** "MP3", "FLAC", "HLS"… from the declared MIME type or the URL. */
|
||||
val format: String?,
|
||||
val sizeBytes: Long?,
|
||||
/** The blob's sha256 (`x`), or the URL: seeds the synthetic waveform and the generated cover. */
|
||||
val seed: String,
|
||||
/** The file's own amplitude envelope (a `waveform` tag), any scale. Null draws the synthetic one. */
|
||||
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
|
||||
get() = listOfNotNull(format, sizeBytes?.let(::formatFileSize)).joinToString(" · ")
|
||||
}
|
||||
|
||||
/** What the player is doing. [durationMs] is null until it has probed the file. */
|
||||
@Immutable
|
||||
data class AudioPlaybackUi(
|
||||
val isPlaying: Boolean = false,
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long? = null,
|
||||
) {
|
||||
val progress: Float
|
||||
get() = durationMs?.takeIf { it > 0 }?.let { (positionMs.toFloat() / it).coerceIn(0f, 1f) } ?: 0f
|
||||
|
||||
companion object {
|
||||
val Idle = AudioPlaybackUi()
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders whichever card [layout] names. [PlayableLayout.VIDEO] is the video player's job and draws nothing here. */
|
||||
@Composable
|
||||
fun PlayableMediaCard(
|
||||
layout: PlayableLayout,
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi,
|
||||
onPlayPause: () -> Unit,
|
||||
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, 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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Known audio: the waveform is the seek bar.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun AudioWaveformCard(
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi,
|
||||
onPlayPause: () -> Unit,
|
||||
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)
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.clickableWithoutRipple(onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlayPauseButton(playback.isPlaying, size = 44.dp, onClick = onPlayPause)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
WaveformBars(
|
||||
bars = bars,
|
||||
progress = playback.progress,
|
||||
played = MaterialTheme.colorScheme.primary,
|
||||
unplayed = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f),
|
||||
modifier = Modifier.fillMaxWidth().height(40.dp),
|
||||
onSeek = onSeek.takeIf { playback.durationMs != null },
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
if (playback.durationMs != null || info.declaredDurationMs != null) formatClock(playback.positionMs) else audioTitle(info),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
trailingFacts(info, playback),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onPictureInPicture != null) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
PictureInPictureButton(onPictureInPicture, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Known audio with artwork: the artwork, with the same scrubber over its foot.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun AudioCoverCard(
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi,
|
||||
onPlayPause: () -> Unit,
|
||||
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)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickableWithoutRipple(onClick),
|
||||
) {
|
||||
// The generated cover sits under the artwork, so a slow or failed load still reads as a cover.
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
// Bottom scrim so the title and the waveform read on any cover.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.6f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.8f)))),
|
||||
)
|
||||
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
audioTitle(info),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
info.artist?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.8f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
PlayPauseButton(playback.isPlaying, size = 52.dp, onClick = onPlayPause, container = Color.White, content = Color.Black)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
WaveformBars(
|
||||
bars = bars,
|
||||
progress = playback.progress,
|
||||
played = Color.White,
|
||||
unplayed = Color.White.copy(alpha = 0.35f),
|
||||
modifier = Modifier.fillMaxWidth().height(36.dp),
|
||||
onSeek = onSeek.takeIf { playback.durationMs != null },
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
playback.durationMs?.let { formatClock(playback.positionMs) } ?: "",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
)
|
||||
Text(
|
||||
trailingFacts(info, playback),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
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.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun UndecidedMediaCard(
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi,
|
||||
onPlayPause: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.clickableWithoutRipple(onClick),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(MaterialSymbols.Podcasts, null, Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
info.title ?: stringRes(Res.string.playable_media_untitled),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
info.artist?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
trailingFacts(info, playback),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PlayPauseButton(playback.isPlaying, size = 48.dp, onClick = onPlayPause)
|
||||
}
|
||||
ThinProgress(playback.progress, Modifier.fillMaxWidth().height(3.dp))
|
||||
}
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Building blocks
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** The file's own waveform when it has one, else the synthetic one every other audio renderer already uses. */
|
||||
@Composable
|
||||
private fun rememberBars(info: AudioCardInfo): List<Float> = remember(info.seed, info.waveform) { info.waveform?.let(::normalizeWave) ?: syntheticWaveformFor(info.seed).wave }
|
||||
|
||||
/** Scales a raw amplitude list (NIP-A0 sends 0..100, others 0..1 or raw RMS) onto 0..1. Null when it has no range. */
|
||||
internal fun normalizeWave(wave: List<Float>): List<Float>? {
|
||||
val max = wave.maxOrNull() ?: return null
|
||||
if (max <= 0f || !max.isFinite()) return null
|
||||
return wave.map { (it / max).coerceIn(0f, 1f) }
|
||||
}
|
||||
|
||||
/** The track's name, or "MP3 audio" when nothing names it. */
|
||||
@Composable
|
||||
private fun audioTitle(info: AudioCardInfo): String =
|
||||
info.title
|
||||
?: info.format?.let { stringRes(Res.string.audio_card_untitled, it) }
|
||||
?: stringRes(Res.string.audio_card_untitled_no_format)
|
||||
|
||||
/** "MP3 · 4.8 MB · 3:28" once the duration is known; the file facts before that. */
|
||||
private fun trailingFacts(
|
||||
info: AudioCardInfo,
|
||||
playback: AudioPlaybackUi,
|
||||
): String = listOfNotNull(info.fileFacts.ifEmpty { null }, (playback.durationMs ?: info.declaredDurationMs)?.let(::formatClock)).joinToString(" · ")
|
||||
|
||||
private fun Modifier.clickableWithoutRipple(onClick: (() -> Unit)?): Modifier =
|
||||
if (onClick == null) {
|
||||
this
|
||||
} else {
|
||||
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,
|
||||
size: Dp,
|
||||
onClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
container: Color = MaterialTheme.colorScheme.primary,
|
||||
content: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(container)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isPlaying) MaterialSymbols.Pause else MaterialSymbols.PlayArrow,
|
||||
contentDescription = stringRes(if (isPlaying) Res.string.pause else Res.string.play),
|
||||
modifier = Modifier.size(size * 0.6f),
|
||||
tint = content,
|
||||
filled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A cover generated from the file hash: a two-tone gradient, optionally a record, optionally a glyph. */
|
||||
@Composable
|
||||
fun GeneratedCover(
|
||||
seed: String,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: MaterialSymbol?,
|
||||
iconSize: Dp = 28.dp,
|
||||
record: Boolean = false,
|
||||
) {
|
||||
val (top, bottom) = remember(seed) { seedColors(seed) }
|
||||
Box(
|
||||
modifier = modifier.background(Brush.linearGradient(listOf(top, bottom))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (record) RecordArt()
|
||||
if (icon != null) {
|
||||
Icon(icon, null, Modifier.size(iconSize), tint = Color.White.copy(alpha = 0.9f), filled = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.RecordArt() {
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
val center = Offset(size.width * 0.62f, size.height * 0.34f)
|
||||
val radius = size.minDimension * 0.4f
|
||||
drawCircle(Color.Black.copy(alpha = 0.55f), radius, center)
|
||||
var r = radius * 0.95f
|
||||
while (r > radius * 0.4f) {
|
||||
drawCircle(Color.White.copy(alpha = 0.06f), r, center, style = Stroke(width = 1.5f))
|
||||
r -= radius * 0.06f
|
||||
}
|
||||
drawCircle(Color.White.copy(alpha = 0.85f), radius * 0.3f, center)
|
||||
drawCircle(Color.Black.copy(alpha = 0.8f), radius * 0.04f, center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinProgress(
|
||||
progress: Float,
|
||||
modifier: Modifier,
|
||||
track: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
fill: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Canvas(modifier) {
|
||||
drawRect(track)
|
||||
drawRect(fill, size = Size(size.width * progress, size.height))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centred bars, coloured [played] up to [progress] and [unplayed] after it. With [onSeek], a tap or a
|
||||
* horizontal drag seeks: the drag previews its position and seeks once, on release.
|
||||
*/
|
||||
@Composable
|
||||
fun WaveformBars(
|
||||
bars: List<Float>,
|
||||
progress: Float,
|
||||
played: Color,
|
||||
unplayed: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
barWidth: Dp = 3.dp,
|
||||
gap: Dp = 2.dp,
|
||||
onSeek: ((Float) -> Unit)? = null,
|
||||
) {
|
||||
var dragFraction by remember { mutableStateOf<Float?>(null) }
|
||||
val currentOnSeek by rememberUpdatedState(onSeek)
|
||||
val seekModifier =
|
||||
if (onSeek == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset -> currentOnSeek?.invoke((offset.x / size.width).coerceIn(0f, 1f)) }
|
||||
}.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { offset -> dragFraction = (offset.x / size.width).coerceIn(0f, 1f) },
|
||||
onDragEnd = {
|
||||
dragFraction?.let { currentOnSeek?.invoke(it) }
|
||||
dragFraction = null
|
||||
},
|
||||
onDragCancel = { dragFraction = null },
|
||||
onHorizontalDrag = { change, _ ->
|
||||
change.consume()
|
||||
dragFraction = (change.position.x / size.width).coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier.then(seekModifier)) {
|
||||
if (bars.isEmpty()) return@Canvas
|
||||
val shown = dragFraction ?: progress
|
||||
val step = (barWidth + gap).toPx()
|
||||
val count = (size.width / step).toInt().coerceAtLeast(1)
|
||||
val w = barWidth.toPx()
|
||||
for (i in 0 until count) {
|
||||
val amp = bars[(i * bars.size) / count].coerceIn(0.08f, 1f)
|
||||
val h = amp * size.height
|
||||
val x = i * step
|
||||
drawRoundRect(
|
||||
color = if (x / size.width < shown) played else unplayed,
|
||||
topLeft = Offset(x, (size.height - h) / 2f),
|
||||
size = Size(w, h),
|
||||
cornerRadius = CornerRadius(w / 2f, w / 2f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Two colours derived from the hash, so every file gets its own stable cover. */
|
||||
fun seedColors(seed: String): Pair<Color, Color> {
|
||||
val n = seed.take(6).toIntOrNull(16) ?: seed.hashCode()
|
||||
val hue = (abs(n) % 360).toFloat()
|
||||
return Color.hsv(hue, 0.6f, 0.62f) to Color.hsv((hue + 48f) % 360f, 0.75f, 0.32f)
|
||||
}
|
||||
|
||||
fun formatClock(millis: Long): String {
|
||||
val seconds = millis / 1000
|
||||
val hours = seconds / 3600
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val secs = (seconds % 60).toString().padStart(2, '0')
|
||||
return if (hours > 0) "$hours:${minutes.toString().padStart(2, '0')}:$secs" else "$minutes:$secs"
|
||||
}
|
||||
|
||||
fun formatFileSize(bytes: Long): String {
|
||||
val tenthsOfMb = (bytes * 10 / 1_048_576.0).roundToInt()
|
||||
return if (tenthsOfMb >= 10) "${tenthsOfMb / 10}.${tenthsOfMb % 10} MB" else "${(bytes / 1024.0).roundToInt()} KB"
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.audio.player
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.unit.Density
|
||||
import org.jetbrains.skia.EncodedImageFormat
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Renders every playable-media card offscreen (no device, no display) through `ImageComposeScene`, in the
|
||||
* dark and light Amethyst themes side by side, and writes each to `commonsUI/build/audio-player/<name>.png`,
|
||||
* so the candidate designs for audio inside a post can be compared as screenshots. Fails if a screen throws
|
||||
* or draws nothing.
|
||||
*/
|
||||
class PlayableMediaCardsRenderTest {
|
||||
private val outDir = File("build/audio-player").apply { mkdirs() }
|
||||
|
||||
private fun render(
|
||||
name: String,
|
||||
widthDp: Int,
|
||||
heightDp: Int,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val density = 2f
|
||||
val width = (widthDp * density).toInt()
|
||||
val height = (heightDp * density).toInt()
|
||||
val scene = ImageComposeScene(width = width, height = height, density = Density(density), content = content)
|
||||
try {
|
||||
// Fonts (including the Material Symbols subset) load asynchronously: let a few frames settle.
|
||||
var image = scene.render(0)
|
||||
repeat(SETTLE_FRAMES) { frame ->
|
||||
Thread.sleep(FRAME_MILLIS)
|
||||
image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L)
|
||||
}
|
||||
val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode $name")
|
||||
File(outDir, "$name.png").writeBytes(png.bytes)
|
||||
|
||||
val pixels = image.peekPixels() ?: error("no pixels for $name")
|
||||
val distinct = HashSet<Int>()
|
||||
for (y in 0 until height step 7) for (x in 0 until width step 7) distinct += pixels.getColor(x, y)
|
||||
assertTrue(distinct.size > MIN_COLOURS, "$name rendered almost nothing (${distinct.size} colours)")
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun knownAudio() = render("01-known-audio-waveform", 820, 760) { AudioKnownAudioPreview() }
|
||||
|
||||
@Test fun withArtwork() = render("02-known-audio-cover", 820, 620) { AudioWithArtworkPreview() }
|
||||
|
||||
@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
|
||||
const val MIN_COLOURS = 20
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user