diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 1fd6a4765a..e69b3f2660 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.audio.syntheticWaveformFor import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note @@ -81,7 +82,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVi import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem -import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index cd419e24fb..b797fb8f64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.audio.WaveformData +import com.vitorpamplona.amethyst.commons.audio.syntheticWaveformFor import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.note.PodcastSoundbites import com.vitorpamplona.amethyst.commons.ui.note.types.PodcastChaptersView @@ -38,7 +39,6 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem -import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor import com.vitorpamplona.quartz.podcasts.PodcastAudio import com.vitorpamplona.quartz.podcasts.PodcastEpisode diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayout.kt new file mode 100644 index 0000000000..1a2d5d1636 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayout.kt @@ -0,0 +1,88 @@ +/* + * 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") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticWaveform.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticWaveform.kt index c4fcbd672c..d4128dbde2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/SyntheticWaveform.kt @@ -18,9 +18,8 @@ * 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.wavefront +package com.vitorpamplona.amethyst.commons.audio -import com.vitorpamplona.amethyst.commons.audio.WaveformData import kotlin.math.PI import kotlin.math.sin import kotlin.random.Random diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayoutTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayoutTest.kt new file mode 100644 index 0000000000..3537d67a69 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayoutTest.kt @@ -0,0 +1,85 @@ +/* + * 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)) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt index ab62f5283f..db97976d6b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt @@ -60,10 +60,13 @@ object AudioPlayerSamples { "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" + private const val URL = "https://npub17u5dneh8qjp43ecfxr6u5e9sjamsmxyuekrg2nlxrrk6nj9rsyrqywt4tp.blossom.band/$HASH.mp3" - /** Straight from the imeta, before ExoPlayer has probed anything: no title, no duration. */ + /** Straight from the imeta, before ExoPlayer has probed anything: no title, no duration, no waveform. */ val notLoaded = AudioCardUi( + url = URL, + mimeType = "audio/mpeg", title = "MP3 audio", artist = AUTHOR_NAME, format = "MP3", @@ -77,6 +80,8 @@ object AudioPlayerSamples { /** Same file, playing: the player now knows it runs 3:28. */ val playing = AudioCardUi( + url = URL, + mimeType = "audio/mpeg", title = "MP3 audio", artist = AUTHOR_NAME, format = "MP3", @@ -87,9 +92,11 @@ object AudioPlayerSamples { seed = HASH, ) - /** A file whose ID3 tags (or imeta `alt`/title) name the track. */ - val tagged = + /** A track with artwork (imeta `image` or an embedded cover) and ID3 tags naming it. */ + val withArtwork = AudioCardUi( + url = "https://blossom.example/3fa1c27be0d94e1a6c55b8f02d7e4a9c1b6f8e03d2a7c5b941e60f8d3c2b1a07.mp3", + mimeType = "audio/mpeg", title = "Segfault in the Cipher", artist = "Esoteric Beats Vol. 1", format = "MP3", @@ -98,6 +105,22 @@ object AudioPlayerSamples { positionSeconds = 72, isPlaying = true, seed = "3fa1c27be0d94e1a6c55b8f02d7e4a9c1b6f8e03d2a7c5b941e60f8d3c2b1a07", + hasArtwork = true, + ) + + /** An HLS playlist: it may carry audio only or video, and nothing tells until the player probes it. */ + val undecided = + AudioCardUi( + url = "https://stream.example/live/index.m3u8", + mimeType = null, + title = "Stream", + artist = AUTHOR_NAME, + format = "HLS", + sizeBytes = null, + durationSeconds = null, + positionSeconds = 0, + isPlaying = false, + seed = "9c41e07d2b5a8f36e1d0c4b7a29f58e3d6c1b0a4f7e2d9c8b5a3f1e0d7c6b4a2", ) } @@ -182,47 +205,33 @@ fun AudioTodayPreview() = @Preview(widthDp = 820, heightDp = 560) @Composable -fun AudioTrackCardPreview() = +fun AudioKnownAudioPreview() = AudioPreviewFrame { Column { - Caption("A · Track card — before the player loads (imeta only)") - MockAudioPost { AudioTrackCardPrototype(AudioPlayerSamples.notLoaded) } + Caption("Known audio, no waveform tag → B with the synthetic waveform · before load") + MockAudioPost { PlayablePostMediaPrototype(AudioPlayerSamples.notLoaded) } Caption("Playing") - MockAudioPost { AudioTrackCardPrototype(AudioPlayerSamples.playing) } - } - } - -@Preview(widthDp = 820, heightDp = 540) -@Composable -fun AudioWaveformCardPreview() = - AudioPreviewFrame { - Column { - Caption("B · Waveform scrubber — before the player loads") - MockAudioPost { AudioWaveformCardPrototype(AudioPlayerSamples.notLoaded) } - Caption("Playing") - MockAudioPost { AudioWaveformCardPrototype(AudioPlayerSamples.playing) } + MockAudioPost { PlayablePostMediaPrototype(AudioPlayerSamples.playing) } } } @Preview(widthDp = 820, heightDp = 620) @Composable -fun AudioCoverCardPreview() = +fun AudioWithArtworkPreview() = AudioPreviewFrame { Column { - Caption("C · Cover — when the file has artwork") - MockAudioPost { AudioCoverCardPrototype(AudioPlayerSamples.tagged) } + Caption("Known audio with artwork → C, the cover with B's scrubber") + MockAudioPost { PlayablePostMediaPrototype(AudioPlayerSamples.withArtwork) } } } -@Preview(widthDp = 820, heightDp = 680) +@Preview(widthDp = 820, heightDp = 360) @Composable -fun AudioVisualizerCardPreview() = +fun AudioUndecidedPreview() = AudioPreviewFrame { Column { - Caption("D · Visualizer card — idle") - MockAudioPost { AudioVisualizerCardPrototype(AudioPlayerSamples.notLoaded) } - Caption("Playing") - MockAudioPost { AudioVisualizerCardPrototype(AudioPlayerSamples.playing) } + Caption("Audio or video? (HLS playlist) → A until the player probes it") + MockAudioPost(text = "Live set tonight, tune in") { PlayablePostMediaPrototype(AudioPlayerSamples.undecided) } } } @@ -263,7 +272,7 @@ fun AudioMiniPlayerPreview() = } } Spacer(Modifier.weight(1f)) - AudioMiniPlayerPrototype(AudioPlayerSamples.tagged, Modifier.padding(horizontal = 8.dp)) + AudioMiniPlayerPrototype(AudioPlayerSamples.withArtwork, Modifier.padding(horizontal = 8.dp)) Spacer(Modifier.height(8.dp)) // Bottom navigation bar placeholder. Row( @@ -277,14 +286,3 @@ fun AudioMiniPlayerPreview() = } } } - -@Preview(widthDp = 820, heightDp = 560) -@Composable -fun AudioTaggedVariantsPreview() = - AudioPreviewFrame { - Column { - Caption("A + B with ID3 tags — a named track") - MockAudioPost { AudioTrackCardPrototype(AudioPlayerSamples.tagged) } - MockAudioPost { AudioWaveformCardPrototype(AudioPlayerSamples.tagged) } - } - } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypes.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypes.kt index f895b85768..688bb933d0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypes.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypes.kt @@ -58,32 +58,38 @@ 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 com.vitorpamplona.amethyst.commons.audio.PlayableLayout +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.close import com.vitorpamplona.amethyst.commons.resources.pause import com.vitorpamplona.amethyst.commons.resources.play -import com.vitorpamplona.amethyst.commons.resources.skip_back -import com.vitorpamplona.amethyst.commons.resources.skip_forward import com.vitorpamplona.amethyst.commons.ui.stringRes import kotlin.math.abs import kotlin.math.roundToInt -import kotlin.math.sin /* - * PROTOTYPES — design exploration for how an audio file inside a post should render, in place of - * today's video player with a square box. Stateless and fed by [AudioCardUi]; nothing here is wired - * to a player yet. Rendered offscreen by `AudioPlayerPrototypesRenderTest` into - * `commonsUI/build/audio-player/`. + * PROTOTYPES — how a playable file inside a post should render, in place of today's video player + * with a square box. Stateless and fed by [AudioCardUi]; nothing here is wired to a player yet. + * Rendered offscreen by `AudioPlayerPrototypesRenderTest` into `commonsUI/build/audio-player/`. * - * Every layout is decided from what the imeta declares (`m`, `size`, `x`, optional `waveform`, - * `image`, `alt`), so the card has its final shape before ExoPlayer has loaded a byte. + * Which card is shown is [playableLayout]'s call, made from the imeta before the player loads a byte: + * - known audio, no artwork -> B, the waveform scrubber (the file's waveform, else a synthetic one); + * - known audio with artwork -> C, the cover with the same scrubber over its foot; + * - audio or video, can't tell (an HLS playlist, say) -> A, the neutral track card, until the + * player's probe settles it; + * - known video -> the video player. */ -/** Everything an audio card shows. [durationSeconds] is null until the player has probed the file. */ +/** Everything a card shows. [durationSeconds] is null until the player has probed the file. */ @Immutable class AudioCardUi( + val url: String, + val mimeType: String?, val title: String, val artist: String, val format: String?, @@ -91,10 +97,12 @@ class AudioCardUi( val durationSeconds: Int?, val positionSeconds: Int, val isPlaying: Boolean, - /** The blob's sha256 (`x`): seeds the cover colours and the placeholder bars, so both are stable per file. */ + /** The blob's sha256 (`x`): seeds the synthetic waveform and the generated cover, so both are stable per file. */ val seed: String, - /** A NIP-A0 or decoded amplitude envelope, 0..1. Null falls back to bars generated from [seed]. */ + /** The file's own amplitude envelope (a `waveform` tag, or one decoded from the audio), 0..1. */ val waveform: List? = null, + /** True when the imeta `image` or an embedded cover gives the file artwork. */ + val hasArtwork: Boolean = false, ) { val progress: Float get() = durationSeconds?.takeIf { it > 0 }?.let { (positionSeconds.toFloat() / it).coerceIn(0f, 1f) } ?: 0f @@ -105,64 +113,36 @@ class AudioCardUi( /** "1:12 / 3:28" while there is a duration; the file facts before that. */ val timeLabel: String - get() = - durationSeconds?.let { "${formatClock(positionSeconds)} / ${formatClock(it)}" } ?: fileFacts + get() = durationSeconds?.let { "${formatClock(positionSeconds)} / ${formatClock(it)}" } ?: fileFacts + + val layout: PlayableLayout + get() = playableLayout(mimeType, url, hasArtwork) } -// --------------------------------------------------------------------------------------------- -// A. Track card — one compact row, like a music-service embed. The recommended default. -// --------------------------------------------------------------------------------------------- - +/** The one entry point a post would call: picks the card from [AudioCardUi.layout]. */ @Composable -fun AudioTrackCardPrototype( - audio: AudioCardUi, +fun PlayablePostMediaPrototype( + media: AudioCardUi, modifier: Modifier = Modifier, ) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Column { - Row( - modifier = Modifier.padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - GeneratedCover(audio.seed, Modifier.size(64.dp).clip(RoundedCornerShape(10.dp)), iconSize = 28.dp) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text( - audio.title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - audio.artist, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(2.dp)) - Text( - if (audio.durationSeconds != null) "${audio.timeLabel} · ${audio.fileFacts}" else audio.fileFacts, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - Spacer(Modifier.width(8.dp)) - PlayPauseButton(audio.isPlaying, size = 48.dp) - } - ThinProgress(audio.progress, Modifier.fillMaxWidth().height(3.dp)) - } + when (media.layout) { + PlayableLayout.AUDIO_WAVEFORM -> AudioWaveformCardPrototype(media, modifier) + PlayableLayout.AUDIO_COVER -> AudioCoverCardPrototype(media, modifier) + PlayableLayout.UNDECIDED -> UndecidedMediaCardPrototype(media, modifier) + // Not prototyped: known video keeps the existing video player. + PlayableLayout.VIDEO -> + Box( + modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black), + ) } } // --------------------------------------------------------------------------------------------- -// B. Waveform scrubber — the voice-note look, generalised: the waveform IS the seek bar. +// B. Waveform scrubber — known audio. The waveform IS the seek bar. // --------------------------------------------------------------------------------------------- @Composable @@ -170,7 +150,7 @@ fun AudioWaveformCardPrototype( audio: AudioCardUi, modifier: Modifier = Modifier, ) { - val bars = remember(audio.seed, audio.waveform) { audio.waveform ?: placeholderBars(audio.seed, 64) } + val bars = rememberBars(audio) Surface( modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), @@ -209,8 +189,8 @@ fun AudioWaveformCardPrototype( } // --------------------------------------------------------------------------------------------- -// C. Cover — for files that carry artwork (imeta `image` or an embedded cover). Here the cover is -// generated from the hash; a real one would replace the gradient and the record. +// C. Cover — known audio with artwork: the cover, with B's scrubber over its foot. The cover here is +// generated from the hash; real artwork would replace the gradient and the record. // --------------------------------------------------------------------------------------------- @Composable @@ -218,6 +198,7 @@ fun AudioCoverCardPrototype( audio: AudioCardUi, modifier: Modifier = Modifier, ) { + val bars = rememberBars(audio) Box( modifier = modifier @@ -226,24 +207,19 @@ fun AudioCoverCardPrototype( .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)), ) { - GeneratedCover(audio.seed, Modifier.fillMaxSize(), iconSize = 0.dp, record = true) + GeneratedCover(audio.seed, Modifier.fillMaxSize(), icon = null, record = true) - // Bottom scrim so the title and controls read on any cover. + // Bottom scrim so the title and the waveform read on any cover. Box( Modifier .fillMaxWidth() - .fillMaxHeight(0.45f) + .fillMaxHeight(0.6f) .align(Alignment.BottomCenter) - .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.75f)))), + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.8f)))), ) - Column( - modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text( audio.title, @@ -254,91 +230,100 @@ fun AudioCoverCardPrototype( overflow = TextOverflow.Ellipsis, ) Text( - "${audio.artist} · ${audio.timeLabel}", + audio.artist, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.8f), maxLines = 1, ) } Spacer(Modifier.width(12.dp)) - PlayPauseButton(audio.isPlaying, size = 56.dp, container = Color.White, content = Color.Black) + PlayPauseButton(audio.isPlaying, size = 52.dp, container = Color.White, content = Color.Black) } Spacer(Modifier.height(12.dp)) - ThinProgress( - audio.progress, - Modifier.fillMaxWidth().height(4.dp), - track = Color.White.copy(alpha = 0.25f), - fill = Color.White, + WaveformBars( + bars = bars, + progress = audio.progress, + played = Color.White, + unplayed = Color.White.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth().height(36.dp), ) + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + audio.durationSeconds?.let { formatClock(audio.positionSeconds) } ?: "", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.8f), + ) + Text( + listOfNotNull(audio.fileFacts, audio.durationSeconds?.let(::formatClock)).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.8f), + ) + } } } } // --------------------------------------------------------------------------------------------- -// D. Visualizer card — keeps the spectrum visualizer, but on a coloured 2:1 card with a static -// frame before playback, so it reads as audio from the first frame instead of a black box. +// A. Neutral track card — only while we can't tell audio from video. It commits to neither shape +// (no waveform, no picture box) and is replaced by B, C or the video player once the probe answers. // --------------------------------------------------------------------------------------------- @Composable -fun AudioVisualizerCardPrototype( - audio: AudioCardUi, +fun UndecidedMediaCardPrototype( + media: AudioCardUi, modifier: Modifier = Modifier, ) { - val (top, bottom) = remember(audio.seed) { seedColors(audio.seed) } - // Stand-in for a live spectrum frame (the real one comes from PcmTapRegistry). - val spectrum = remember(audio.seed) { placeholderBars(audio.seed.reversed(), 40) } - Box( - modifier = - modifier - .fillMaxWidth() - .aspectRatio(2f) - .clip(RoundedCornerShape(16.dp)) - .background(Brush.linearGradient(listOf(top, bottom))), + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - Column(Modifier.fillMaxSize().padding(14.dp)) { - Text( - audio.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text(audio.artist, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.8f), maxLines = 1) - - WaveformBars( - bars = spectrum, - progress = 0f, - played = Color.White, - unplayed = Color.White.copy(alpha = if (audio.isPlaying) 0.85f else 0.4f), - modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 10.dp), - fromBottom = true, - barWidth = 5.dp, - gap = 3.dp, - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(MaterialSymbols.Replay10, stringRes(Res.string.skip_back, 10), Modifier.size(26.dp), tint = Color.White) - Spacer(Modifier.width(10.dp)) - PlayPauseButton(audio.isPlaying, size = 40.dp, container = Color.White, content = Color.Black) - Spacer(Modifier.width(10.dp)) - Icon(MaterialSymbols.Forward10, stringRes(Res.string.skip_forward, 10), Modifier.size(26.dp), tint = Color.White) + Column { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(64.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.Podcasts, null, Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } Spacer(Modifier.width(12.dp)) - ThinProgress( - audio.progress, - Modifier.weight(1f).height(4.dp).clip(CircleShape), - track = Color.White.copy(alpha = 0.3f), - fill = Color.White, - ) - Spacer(Modifier.width(10.dp)) - Text(audio.timeLabel, style = MaterialTheme.typography.labelSmall, color = Color.White) + Column(Modifier.weight(1f)) { + Text( + media.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + media.artist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + if (media.durationSeconds != null) "${media.timeLabel} · ${media.fileFacts}" else media.fileFacts, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Spacer(Modifier.width(8.dp)) + PlayPauseButton(media.isPlaying, size = 48.dp) } + ThinProgress(media.progress, Modifier.fillMaxWidth().height(3.dp)) } } } // --------------------------------------------------------------------------------------------- -// E. Mini player — where a playing track goes once its post scrolls away. Complements A–D. +// E. Mini player — where a playing track goes once its post scrolls away. Complements B and C. // --------------------------------------------------------------------------------------------- @Composable @@ -357,7 +342,7 @@ fun AudioMiniPlayerPrototype( modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - GeneratedCover(audio.seed, Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)), iconSize = 20.dp) + GeneratedCover(audio.seed, Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)), icon = MaterialSymbols.MusicNote, iconSize = 20.dp) Spacer(Modifier.width(10.dp)) Column(Modifier.weight(1f)) { Text(audio.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -375,6 +360,10 @@ fun AudioMiniPlayerPrototype( // 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(audio: AudioCardUi): List = remember(audio.seed, audio.waveform) { audio.waveform ?: syntheticWaveformFor(audio.seed).wave } + @Composable fun PlayPauseButton( isPlaying: Boolean, @@ -397,12 +386,13 @@ fun PlayPauseButton( } } -/** A cover generated from the file hash: a two-tone gradient, optionally a record, optionally a note glyph. */ +/** 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, - iconSize: Dp, + icon: MaterialSymbol?, + iconSize: Dp = 28.dp, record: Boolean = false, ) { val (top, bottom) = remember(seed) { seedColors(seed) } @@ -411,8 +401,8 @@ fun GeneratedCover( contentAlignment = Alignment.Center, ) { if (record) RecordArt() - if (iconSize > 0.dp) { - Icon(MaterialSymbols.MusicNote, null, Modifier.size(iconSize), tint = Color.White.copy(alpha = 0.9f), filled = true) + if (icon != null) { + Icon(icon, null, Modifier.size(iconSize), tint = Color.White.copy(alpha = 0.9f), filled = true) } } } @@ -420,8 +410,8 @@ fun GeneratedCover( @Composable private fun BoxScope.RecordArt() { Canvas(Modifier.matchParentSize()) { - val center = Offset(size.width * 0.62f, size.height * 0.38f) - val radius = size.minDimension * 0.42f + 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) { @@ -446,7 +436,7 @@ private fun ThinProgress( } } -/** Bars coloured [played] up to [progress] and [unplayed] after it; centred, or grown up [fromBottom]. */ +/** Centred bars, coloured [played] up to [progress] and [unplayed] after it. */ @Composable fun WaveformBars( bars: List, @@ -454,7 +444,6 @@ fun WaveformBars( played: Color, unplayed: Color, modifier: Modifier = Modifier, - fromBottom: Boolean = false, barWidth: Dp = 3.dp, gap: Dp = 2.dp, ) { @@ -466,10 +455,9 @@ fun WaveformBars( val amp = bars[(i * bars.size) / count].coerceIn(0.08f, 1f) val h = amp * size.height val x = i * step - val y = if (fromBottom) size.height - h else (size.height - h) / 2f drawRoundRect( color = if (x / size.width < progress) played else unplayed, - topLeft = Offset(x, y), + topLeft = Offset(x, (size.height - h) / 2f), size = Size(w, h), cornerRadius = CornerRadius(w / 2f, w / 2f), ) @@ -484,25 +472,6 @@ fun seedColors(seed: String): Pair { return Color.hsv(hue, 0.6f, 0.62f) to Color.hsv((hue + 48f) % 360f, 0.75f, 0.32f) } -/** A music-looking envelope generated from the hash: the stand-in until a real waveform exists. */ -fun placeholderBars( - seed: String, - count: Int, -): List { - val hex = seed.ifEmpty { "0" } - val raw = - List(count) { i -> - val nibble = hex[i % hex.length].digitToIntOrNull(16) ?: 8 - 0.3f + 0.45f * (nibble / 15f) + 0.25f * abs(sin(i * 0.45f)) - } - // Light smoothing so neighbouring bars relate, as in real audio. - return List(count) { i -> - val prev = raw[(i - 1).coerceAtLeast(0)] - val next = raw[(i + 1).coerceAtMost(count - 1)] - ((prev + 2 * raw[i] + next) / 4f).coerceIn(0f, 1f) - } -} - fun formatClock(seconds: Int): String = "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}" fun formatFileSize(bytes: Long): String { diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypesRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypesRenderTest.kt index 809676f090..d2822b3daf 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypesRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypesRenderTest.kt @@ -68,17 +68,13 @@ class AudioPlayerPrototypesRenderTest { @Test fun today() = render("00-today", 820, 700) { AudioTodayPreview() } - @Test fun trackCard() = render("01-track-card", 820, 560) { AudioTrackCardPreview() } + @Test fun knownAudio() = render("01-known-audio-waveform", 820, 560) { AudioKnownAudioPreview() } - @Test fun waveformCard() = render("02-waveform-scrubber", 820, 540) { AudioWaveformCardPreview() } + @Test fun withArtwork() = render("02-known-audio-cover", 820, 620) { AudioWithArtworkPreview() } - @Test fun coverCard() = render("03-cover", 820, 620) { AudioCoverCardPreview() } + @Test fun undecided() = render("03-undecided-track-card", 820, 360) { AudioUndecidedPreview() } - @Test fun visualizerCard() = render("04-visualizer-card", 820, 680) { AudioVisualizerCardPreview() } - - @Test fun miniPlayer() = render("05-mini-player", 820, 520) { AudioMiniPlayerPreview() } - - @Test fun taggedVariants() = render("06-tagged-track", 820, 560) { AudioTaggedVariantsPreview() } + @Test fun miniPlayer() = render("04-mini-player", 820, 520) { AudioMiniPlayerPreview() } private companion object { const val SETTLE_FRAMES = 12