From 949cd457989f881a45e852368e42590d2d39b178 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:16:51 +0000 Subject: [PATCH 1/5] feat(audio): prototype audio-in-post players with offscreen screenshots Five stateless Compose prototypes for rendering an audio file inside a post instead of the video player's black square: a compact track card, a waveform scrubber, a cover-art card, a gradient visualizer card and a mini player. Each is laid out from the imeta alone (m, size, x), so it has its final shape before ExoPlayer loads anything. AudioPlayerPrototypesRenderTest renders them in dark and light themes to commonsUI/build/audio-player/*.png. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE --- .../audio/player/AudioPlayerPreviews.kt | 290 ++++++++++ .../audio/player/AudioPlayerPrototypes.kt | 511 ++++++++++++++++++ .../player/AudioPlayerPrototypesRenderTest.kt | 88 +++ 3 files changed, 889 insertions(+) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypes.kt create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypesRenderTest.kt 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 new file mode 100644 index 0000000000..ab62f5283f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt @@ -0,0 +1,290 @@ +/* + * 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.Arrangement +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.aspectRatio +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.graphics.Color +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.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +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 redesign (an MP3 with `m audio/mpeg`, no ID3 tags), plus richer variants. */ +object AudioPlayerSamples { + const val AUTHOR_HEX = "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106" + const val AUTHOR_NAME = "npub17u5d…t4tp" + 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" + + /** Straight from the imeta, before ExoPlayer has probed anything: no title, no duration. */ + val notLoaded = + AudioCardUi( + title = "MP3 audio", + artist = AUTHOR_NAME, + format = "MP3", + sizeBytes = 4_992_768, + durationSeconds = null, + positionSeconds = 0, + isPlaying = false, + seed = HASH, + ) + + /** Same file, playing: the player now knows it runs 3:28. */ + val playing = + AudioCardUi( + title = "MP3 audio", + artist = AUTHOR_NAME, + format = "MP3", + sizeBytes = 4_992_768, + durationSeconds = 208, + positionSeconds = 72, + isPlaying = true, + seed = HASH, + ) + + /** A file whose ID3 tags (or imeta `alt`/title) name the track. */ + val tagged = + AudioCardUi( + title = "Segfault in the Cipher", + artist = "Esoteric Beats Vol. 1", + format = "MP3", + sizeBytes = 4_992_768, + durationSeconds = 208, + positionSeconds = 72, + isPlaying = true, + seed = "3fa1c27be0d94e1a6c55b8f02d7e4a9c1b6f8e03d2a7c5b941e60f8d3c2b1a07", + ) +} + +/** 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), + ) +} + +/** Roughly what ships today: the video player's black square, controls and all, until tracks resolve. */ +@Composable +private fun TodaysPlayerSketch() { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.PlayArrow, null, Modifier.size(64.dp), tint = Color.White, filled = true) + Row( + Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("0:00", style = MaterialTheme.typography.labelSmall, color = Color.White) + Box(Modifier.weight(1f).height(3.dp).background(Color.White.copy(alpha = 0.4f))) + Text("--:--", style = MaterialTheme.typography.labelSmall, color = Color.White) + Icon(MaterialSymbols.Fullscreen, null, Modifier.size(20.dp), tint = Color.White) + } + } +} + +@Preview(widthDp = 820, heightDp = 700) +@Composable +fun AudioTodayPreview() = + AudioPreviewFrame { + Column { + Caption("Today · the video player's black square, until ExoPlayer reports tracks") + MockAudioPost { TodaysPlayerSketch() } + } + } + +@Preview(widthDp = 820, heightDp = 560) +@Composable +fun AudioTrackCardPreview() = + AudioPreviewFrame { + Column { + Caption("A · Track card — before the player loads (imeta only)") + MockAudioPost { AudioTrackCardPrototype(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) } + } + } + +@Preview(widthDp = 820, heightDp = 620) +@Composable +fun AudioCoverCardPreview() = + AudioPreviewFrame { + Column { + Caption("C · Cover — when the file has artwork") + MockAudioPost { AudioCoverCardPrototype(AudioPlayerSamples.tagged) } + } + } + +@Preview(widthDp = 820, heightDp = 680) +@Composable +fun AudioVisualizerCardPreview() = + AudioPreviewFrame { + Column { + Caption("D · Visualizer card — idle") + MockAudioPost { AudioVisualizerCardPrototype(AudioPlayerSamples.notLoaded) } + Caption("Playing") + MockAudioPost { AudioVisualizerCardPrototype(AudioPlayerSamples.playing) } + } + } + +@Preview(widthDp = 820, heightDp = 520) +@Composable +fun AudioMiniPlayerPreview() = + AudioPreviewFrame { + Column(Modifier.fillMaxSize()) { + Caption("E · Mini player — after the post scrolls away") + // Ghost feed rows standing in for the notes that scrolled in. + repeat(3) { + Row(Modifier.fillMaxWidth().padding(12.dp)) { + Box(Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh)) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier + .width(120.dp) + .height(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) + Box( + Modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + Box( + Modifier + .fillMaxWidth(0.7f) + .height(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } + } + } + Spacer(Modifier.weight(1f)) + AudioMiniPlayerPrototype(AudioPlayerSamples.tagged, Modifier.padding(horizontal = 8.dp)) + Spacer(Modifier.height(8.dp)) + // Bottom navigation bar placeholder. + Row( + Modifier.fillMaxWidth().height(56.dp).background(MaterialTheme.colorScheme.surfaceContainer), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + listOf(MaterialSymbols.Home, MaterialSymbols.Search, MaterialSymbols.Notifications).forEach { + Icon(it, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + +@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 new file mode 100644 index 0000000000..f895b85768 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypes.kt @@ -0,0 +1,511 @@ +/* + * 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.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.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +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.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.icons.symbols.Icon +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/`. + * + * 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. + */ + +/** Everything an audio card shows. [durationSeconds] is null until the player has probed the file. */ +@Immutable +class AudioCardUi( + val title: String, + val artist: String, + val format: String?, + val sizeBytes: Long?, + 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. */ + val seed: String, + /** A NIP-A0 or decoded amplitude envelope, 0..1. Null falls back to bars generated from [seed]. */ + val waveform: List? = null, +) { + val progress: Float + get() = durationSeconds?.takeIf { it > 0 }?.let { (positionSeconds.toFloat() / it).coerceIn(0f, 1f) } ?: 0f + + /** "MP3 · 4.8 MB" — known from the imeta alone. */ + val fileFacts: String + get() = listOfNotNull(format, sizeBytes?.let(::formatFileSize)).joinToString(" · ") + + /** "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 +} + +// --------------------------------------------------------------------------------------------- +// A. Track card — one compact row, like a music-service embed. The recommended default. +// --------------------------------------------------------------------------------------------- + +@Composable +fun AudioTrackCardPrototype( + audio: 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)) + } + } +} + +// --------------------------------------------------------------------------------------------- +// B. Waveform scrubber — the voice-note look, generalised: the waveform IS the seek bar. +// --------------------------------------------------------------------------------------------- + +@Composable +fun AudioWaveformCardPrototype( + audio: AudioCardUi, + modifier: Modifier = Modifier, +) { + val bars = remember(audio.seed, audio.waveform) { audio.waveform ?: placeholderBars(audio.seed, 64) } + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PlayPauseButton(audio.isPlaying, size = 44.dp) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + WaveformBars( + bars = bars, + progress = audio.progress, + played = MaterialTheme.colorScheme.primary, + unplayed = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth().height(40.dp), + ) + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + audio.durationSeconds?.let { formatClock(audio.positionSeconds) } ?: audio.title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + listOfNotNull(audio.fileFacts, audio.durationSeconds?.let(::formatClock)).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------------------------- + +@Composable +fun AudioCoverCardPrototype( + audio: AudioCardUi, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)), + ) { + GeneratedCover(audio.seed, Modifier.fillMaxSize(), iconSize = 0.dp, record = true) + + // Bottom scrim so the title and controls read on any cover. + Box( + Modifier + .fillMaxWidth() + .fillMaxHeight(0.45f) + .align(Alignment.BottomCenter) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.75f)))), + ) + + Column( + modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + audio.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${audio.artist} · ${audio.timeLabel}", + 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) + } + Spacer(Modifier.height(12.dp)) + ThinProgress( + audio.progress, + Modifier.fillMaxWidth().height(4.dp), + track = Color.White.copy(alpha = 0.25f), + fill = Color.White, + ) + } + } +} + +// --------------------------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------------------------- + +@Composable +fun AudioVisualizerCardPrototype( + audio: 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))), + ) { + 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) + 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) + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// E. Mini player — where a playing track goes once its post scrolls away. Complements A–D. +// --------------------------------------------------------------------------------------------- + +@Composable +fun AudioMiniPlayerPrototype( + audio: AudioCardUi, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shadowElevation = 6.dp, + ) { + Column { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GeneratedCover(audio.seed, Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)), 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) + Text(audio.artist, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + PlayPauseButton(audio.isPlaying, size = 40.dp, container = Color.Transparent, content = MaterialTheme.colorScheme.onSurface) + Icon(MaterialSymbols.Close, stringRes(Res.string.close), Modifier.padding(horizontal = 8.dp).size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ThinProgress(audio.progress, Modifier.fillMaxWidth().height(2.dp)) + } + } +} + +// --------------------------------------------------------------------------------------------- +// Building blocks +// --------------------------------------------------------------------------------------------- + +@Composable +fun PlayPauseButton( + isPlaying: Boolean, + size: Dp, + modifier: Modifier = Modifier, + container: Color = MaterialTheme.colorScheme.primary, + content: Color = MaterialTheme.colorScheme.onPrimary, +) { + Box( + modifier = modifier.size(size).clip(CircleShape).background(container), + 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 note glyph. */ +@Composable +fun GeneratedCover( + seed: String, + modifier: Modifier = Modifier, + iconSize: 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 (iconSize > 0.dp) { + Icon(MaterialSymbols.MusicNote, 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.38f) + val radius = size.minDimension * 0.42f + 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)) + } +} + +/** Bars coloured [played] up to [progress] and [unplayed] after it; centred, or grown up [fromBottom]. */ +@Composable +fun WaveformBars( + bars: List, + progress: Float, + played: Color, + unplayed: Color, + modifier: Modifier = Modifier, + fromBottom: Boolean = false, + barWidth: Dp = 3.dp, + gap: Dp = 2.dp, +) { + Canvas(modifier) { + 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 + 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), + 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 { + 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) +} + +/** 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 { + val tenthsOfMb = (bytes * 10 / 1_048_576.0).roundToInt() + return if (tenthsOfMb >= 10) "${tenthsOfMb / 10}.${tenthsOfMb % 10} MB" else "${(bytes / 1024.0).roundToInt()} KB" +} 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 new file mode 100644 index 0000000000..809676f090 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypesRenderTest.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.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 audio-player prototype 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/.png`, + * so the candidate designs for audio inside a post can be compared as screenshots. Fails if a screen throws + * or draws nothing. + */ +class AudioPlayerPrototypesRenderTest { + 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() + 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 today() = render("00-today", 820, 700) { AudioTodayPreview() } + + @Test fun trackCard() = render("01-track-card", 820, 560) { AudioTrackCardPreview() } + + @Test fun waveformCard() = render("02-waveform-scrubber", 820, 540) { AudioWaveformCardPreview() } + + @Test fun coverCard() = render("03-cover", 820, 620) { AudioCoverCardPreview() } + + @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() } + + private companion object { + const val SETTLE_FRAMES = 12 + const val FRAME_MILLIS = 60L + const val MIN_COLOURS = 20 + } +} From 6fdd2583942b1a535831bccfacc352a60cccaec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:52:23 +0000 Subject: [PATCH 2/5] feat(audio): pick the in-post audio card from what the file declares playableLayout() encodes the rule: known audio without artwork gets the waveform scrubber (its own waveform, else the synthetic one), known audio with artwork gets the cover with that scrubber, and the neutral track card is only for files that could be audio or video (HLS playlists, no type), until the player's probe settles it. Known video keeps the video player. syntheticWaveformFor moves from amethyst to commons so the shared cards can use the same fake waveform music tracks and podcast episodes already draw. The prototypes now dispatch through playableLayout and drop the visualizer card. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE --- .../amethyst/ui/note/types/MusicTrack.kt | 2 +- .../note/types/PodcastEpisodeAudioPlayer.kt | 2 +- .../amethyst/commons/audio/PlayableLayout.kt | 88 ++++++ .../commons/audio}/SyntheticWaveform.kt | 3 +- .../commons/audio/PlayableLayoutTest.kt | 85 +++++ .../audio/player/AudioPlayerPreviews.kt | 78 +++-- .../audio/player/AudioPlayerPrototypes.kt | 295 ++++++++---------- .../player/AudioPlayerPrototypesRenderTest.kt | 12 +- 8 files changed, 350 insertions(+), 215 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayout.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio}/SyntheticWaveform.kt (96%) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayoutTest.kt 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 From 7c4aa18b340176d31950fdfeda5f29fda133445d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:25:06 +0000 Subject: [PATCH 3/5] feat(audio): render audio in posts as waveform or cover cards Inline media in a post now goes through playableLayout(): known audio renders as a waveform scrubber (the imeta waveform when present, else the synthetic one), known audio with an imeta image as a cover card with the same scrubber, and files that could be audio or video (HLS playlists) as a neutral card until the player's probe settles it. Known video and live streams keep the video player, and a declared-audio file whose tracks turn out to include video moves to it. Probe results are cached per URL so a post scrolled back into view keeps its shape. The cards are stateless in commonsUI (PlayableMediaCards.kt) and driven on Android by PlayableAudioView over the pooled player: no autoplay, no loop, nothing fetched before play when automatic media loading is off; tap or drag on the waveform seeks, and tapping the card shows the share, save, picture-in-picture and mute buttons the voice player uses. MediaUrlVideo now carries the imeta size and waveform, so the card shows the file size and real waveform before anything loads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE --- .../ui/components/PlayableAudioView.kt | 280 +++++++++++++ .../ui/components/ZoomableContentView.kt | 119 ++++-- .../amethyst/commons/audio/PlayableLayout.kt | 44 +++ .../commons/richtext/MediaContentModels.kt | 4 + .../commons/richtext/RichTextParser.kt | 4 + .../commons/audio/PlayableLayoutTest.kt | 12 + .../richtext/RichTextParserAudioImetaTest.kt | 60 +++ .../composeResources/values/strings.xml | 3 + .../audio/player/AudioPlayerPreviews.kt | 288 -------------- .../audio/player/PlayableMediaCardPreviews.kt | 177 +++++++++ ...yerPrototypes.kt => PlayableMediaCards.kt} | 367 +++++++++++------- ...est.kt => PlayableMediaCardsRenderTest.kt} | 10 +- 12 files changed, 888 insertions(+), 480 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt delete mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt rename commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/{AudioPlayerPrototypes.kt => PlayableMediaCards.kt} (54%) rename commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/{AudioPlayerPrototypesRenderTest.kt => PlayableMediaCardsRenderTest.kt} (90%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt new file mode 100644 index 0000000000..14d543062d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt @@ -0,0 +1,280 @@ +/* + * 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 androidx.annotation.OptIn +import androidx.collection.LruCache +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.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.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.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.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem +import com.vitorpamplona.amethyst.ui.note.types.RenderTopButtonsForVoice +import kotlinx.coroutines.delay + +/** + * What the player found when it probed a URL: true when every track is audio, false when there is a + * 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(500) + + fun get(url: String): Boolean? = cache[url] + + fun put( + url: String, + audioOnly: Boolean, + ) { + cache.put(url, audioOnly) + } +} + +/** + * 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. + */ +@Composable +fun PlayableAudioView( + content: MediaUrlVideo, + layout: PlayableLayout, + onProbed: (audioOnly: Boolean) -> Unit, + accountViewModel: AccountViewModel, +) { + 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, + ) + } + + // 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) + } + + 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?>(null) } + val controllerVisible = remember(content.url) { mutableStateOf(false) } + + if (load) { + val proxyPort = remember(content.url) { accountViewModel.httpClientBuilder.proxyPortForVideo(content.url) } + GetMediaItem( + videoUri = content.url, + title = mediaTitle, + artworkUri = content.artworkUri, + authorName = authorName, + callbackUri = content.uri, + mimeType = content.mimeType, + proxyPort = proxyPort, + keepPlaying = false, + isLiveStream = content.isLiveStream, + hash = content.hash, + ) { mediaItem -> + GetVideoController(mediaItem = mediaItem, muted = false) { controller -> + PauseControllerWhenInBackground(controller) + ReportProbe(controller.controller, onProbed) + + DisposableEffect(controller, mediaItem) { + active = controller to mediaItem + onDispose { + if (active?.first === controller) active = null + } + } + + // Play was pressed before the controller connected. + LaunchedEffect(controller) { + if (playWhenConnected) { + playWhenConnected = false + controller.controller.play() + } + } + } + } + } + + Box { + val current = active + if (current != null) { + val (controller, mediaItem) = current + val playback by rememberAudioPlaybackUi(controller.controller) + 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 }, + modifier = + Modifier.onGloballyPositioned { coordinates -> + // Where picture-in-picture animates from. + val bounds = coordinates.boundsInWindow() + controller.visibility.setBounds(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) + }, + overlay = { + RenderTopButtonsForVoice( + mediaData = mediaItem.src, + controllerState = controller, + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.TopEnd), + accountViewModel = accountViewModel, + ) + }, + ) + } else { + PlayableMediaCard( + layout = layout, + info = info, + playback = AudioPlaybackUi.Idle, + onPlayPause = { + playWhenConnected = true + load = true + }, + onSeek = {}, + ) + } + } +} + +@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) } + } +} + +/** The player's state as the card draws it: event-driven, plus a position poll while it plays. */ +@Composable +private fun rememberAudioPlaybackUi(player: Player): State { + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 40c99907e2..a8bbd39164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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,53 +233,86 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - // 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 = - remember(content.url, content.mimeType) { - unknownMediaAspectRatio(content.mimeType, content.url) + // 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) + } } - val ratio = - content.dim?.aspectRatioOrNull() - ?: MediaAspectRatioCache.get(content.url) - ?: fallbackRatio - ContentWarningGate( - isSensitive = content.contentWarning != null, - reasons = setOfNotNull(content.contentWarning), - preloadUrls = emptyList(), - accountViewModel = accountViewModel, - modifier = mediaSizingModifier(ratio, contentScale), - backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } }, - ) { - Box( - // The sizing modifier is repeated here because ContentWarningGate only applies - // the one it is handed when the content is actually sensitive — the common - // non-sensitive path emits content() bare. Without a height constraint of its - // own this box stretches to whatever ceiling encloses it and the player - // letterboxes the frame inside, which is what put black bars above and below - // live streams (their enclosure is StreamingHeaderModifier's 300.dp cap). - modifier = mediaSizingModifier(ratio, contentScale).then(boundsTrackingModifier), - contentAlignment = Alignment.Center, + + if (layout != PlayableLayout.VIDEO) { + ContentWarningGate( + isSensitive = content.contentWarning != null, + reasons = setOfNotNull(content.contentWarning), + preloadUrls = emptyList(), + accountViewModel = accountViewModel, ) { - VideoView( - videoUri = content.url, - mimeType = content.mimeType, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - dimensions = content.dim, - blurhash = content.blurhash, - roundedCorner = roundedCorner, - contentScale = contentScale, - nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, + PlayableAudioView( + content = content, + layout = layout, + onProbed = { audioOnly -> + PlayableMediaProbeCache.put(content.url, audioOnly) + probedAudioOnly = audioOnly + }, accountViewModel = accountViewModel, - thumbhash = content.thumbhash, - isLiveStream = content.isLiveStream, - hash = content.hash, - captions = content.captions, ) } + } 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 = + remember(content.url, content.mimeType) { + unknownMediaAspectRatio(content.mimeType, content.url) + } + val ratio = + content.dim?.aspectRatioOrNull() + ?: MediaAspectRatioCache.get(content.url) + ?: fallbackRatio + ContentWarningGate( + isSensitive = content.contentWarning != null, + reasons = setOfNotNull(content.contentWarning), + preloadUrls = emptyList(), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } }, + ) { + Box( + // The sizing modifier is repeated here because ContentWarningGate only applies + // the one it is handed when the content is actually sensitive — the common + // non-sensitive path emits content() bare. Without a height constraint of its + // own this box stretches to whatever ceiling encloses it and the player + // letterboxes the frame inside, which is what put black bars above and below + // live streams (their enclosure is StreamingHeaderModifier's 300.dp cap). + modifier = mediaSizingModifier(ratio, contentScale).then(boundsTrackingModifier), + contentAlignment = Alignment.Center, + ) { + VideoView( + videoUri = content.url, + mimeType = content.mimeType, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + dimensions = content.dim, + blurhash = content.blurhash, + roundedCorner = roundedCorner, + contentScale = contentScale, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, + accountViewModel = accountViewModel, + thumbhash = content.thumbhash, + isLiveStream = content.isLiveStream, + hash = content.hash, + captions = content.captions, + ) + } + } } } 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 index 1a2d5d1636..7bac3887d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayout.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayout.kt @@ -86,3 +86,47 @@ fun declaredAsAudio( } 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 +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 5f32aea49e..6148d22d99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -118,6 +118,10 @@ open class MediaUrlVideo( authorPubKey: String? = null, // NIP-71 `text-track` captions resolved for this video, for the player to side-load. val captions: ImmutableList = 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? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey) @Immutable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index bc7b83c218..63d1a009cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -38,7 +38,9 @@ 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.WaveformTag import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -95,6 +97,8 @@ 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) }, ) } else if (kind == MediaContentKind.PDF) { MediaUrlPdf( 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 index 3537d67a69..e34e38cca3 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayoutTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/PlayableLayoutTest.kt @@ -82,4 +82,16 @@ class PlayableLayoutTest { // 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")) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt new file mode 100644 index 0000000000..121a6d2dbb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt @@ -0,0 +1,60 @@ +/* + * 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(media) + assertEquals("audio/mpeg", media.mimeType) + assertEquals(4_992_768L, media.sizeBytes) + assertNull(media.waveform) + } + + @Test + fun waveformReachesTheMedia() { + val media = parse("m audio/mpeg", "waveform 0 7 35 100 42") + assertIs(media) + assertEquals(listOf(0f, 7f, 35f, 100f, 42f), media.waveform) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 868b66caca..8fae0ac17e 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6361,4 +6361,7 @@ This file type can run code. Check that the name matches what you meant to get. Save From %1$s + %1$s audio + Audio + Media 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 deleted file mode 100644 index db97976d6b..0000000000 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPreviews.kt +++ /dev/null @@ -1,288 +0,0 @@ -/* - * 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.Arrangement -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.aspectRatio -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.graphics.Color -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.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -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 redesign (an MP3 with `m audio/mpeg`, no ID3 tags), plus richer variants. */ -object AudioPlayerSamples { - const val AUTHOR_HEX = "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106" - const val AUTHOR_NAME = "npub17u5d…t4tp" - 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" - private const val URL = "https://npub17u5dneh8qjp43ecfxr6u5e9sjamsmxyuekrg2nlxrrk6nj9rsyrqywt4tp.blossom.band/$HASH.mp3" - - /** 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", - sizeBytes = 4_992_768, - durationSeconds = null, - positionSeconds = 0, - isPlaying = false, - seed = HASH, - ) - - /** 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", - sizeBytes = 4_992_768, - durationSeconds = 208, - positionSeconds = 72, - isPlaying = true, - seed = HASH, - ) - - /** 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", - sizeBytes = 4_992_768, - durationSeconds = 208, - 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", - ) -} - -/** 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), - ) -} - -/** Roughly what ships today: the video player's black square, controls and all, until tracks resolve. */ -@Composable -private fun TodaysPlayerSketch() { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(12.dp)) - .background(Color.Black), - contentAlignment = Alignment.Center, - ) { - Icon(MaterialSymbols.PlayArrow, null, Modifier.size(64.dp), tint = Color.White, filled = true) - Row( - Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("0:00", style = MaterialTheme.typography.labelSmall, color = Color.White) - Box(Modifier.weight(1f).height(3.dp).background(Color.White.copy(alpha = 0.4f))) - Text("--:--", style = MaterialTheme.typography.labelSmall, color = Color.White) - Icon(MaterialSymbols.Fullscreen, null, Modifier.size(20.dp), tint = Color.White) - } - } -} - -@Preview(widthDp = 820, heightDp = 700) -@Composable -fun AudioTodayPreview() = - AudioPreviewFrame { - Column { - Caption("Today · the video player's black square, until ExoPlayer reports tracks") - MockAudioPost { TodaysPlayerSketch() } - } - } - -@Preview(widthDp = 820, heightDp = 560) -@Composable -fun AudioKnownAudioPreview() = - AudioPreviewFrame { - Column { - Caption("Known audio, no waveform tag → B with the synthetic waveform · before load") - MockAudioPost { PlayablePostMediaPrototype(AudioPlayerSamples.notLoaded) } - Caption("Playing") - MockAudioPost { PlayablePostMediaPrototype(AudioPlayerSamples.playing) } - } - } - -@Preview(widthDp = 820, heightDp = 620) -@Composable -fun AudioWithArtworkPreview() = - AudioPreviewFrame { - Column { - Caption("Known audio with artwork → C, the cover with B's scrubber") - MockAudioPost { PlayablePostMediaPrototype(AudioPlayerSamples.withArtwork) } - } - } - -@Preview(widthDp = 820, heightDp = 360) -@Composable -fun AudioUndecidedPreview() = - AudioPreviewFrame { - Column { - Caption("Audio or video? (HLS playlist) → A until the player probes it") - MockAudioPost(text = "Live set tonight, tune in") { PlayablePostMediaPrototype(AudioPlayerSamples.undecided) } - } - } - -@Preview(widthDp = 820, heightDp = 520) -@Composable -fun AudioMiniPlayerPreview() = - AudioPreviewFrame { - Column(Modifier.fillMaxSize()) { - Caption("E · Mini player — after the post scrolls away") - // Ghost feed rows standing in for the notes that scrolled in. - repeat(3) { - Row(Modifier.fillMaxWidth().padding(12.dp)) { - Box(Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh)) - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Box( - Modifier - .width(120.dp) - .height(12.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - ) - Box( - Modifier - .fillMaxWidth() - .height(12.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) - Box( - Modifier - .fillMaxWidth(0.7f) - .height(12.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) - } - } - } - Spacer(Modifier.weight(1f)) - AudioMiniPlayerPrototype(AudioPlayerSamples.withArtwork, Modifier.padding(horizontal = 8.dp)) - Spacer(Modifier.height(8.dp)) - // Bottom navigation bar placeholder. - Row( - Modifier.fillMaxWidth().height(56.dp).background(MaterialTheme.colorScheme.surfaceContainer), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - listOf(MaterialSymbols.Home, MaterialSymbols.Search, MaterialSymbols.Notifications).forEach { - Icon(it, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt new file mode 100644 index 0000000000..d3e3e38bc4 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt @@ -0,0 +1,177 @@ +/* + * 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.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) + + val playing = AudioPlaybackUi(isPlaying = true, positionMs = 72_000, durationMs = 208_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, +) = PlayableMediaCard(layout, info, playback, onPlayPause = {}, onSeek = {}) + +@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) } + } + } 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/PlayableMediaCards.kt similarity index 54% rename from commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypes.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCards.kt index 688bb933d0..26b654cf58 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/PlayableMediaCards.kt @@ -22,6 +22,9 @@ 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 @@ -40,11 +43,14 @@ 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.Surface 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 @@ -54,160 +60,193 @@ 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.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.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.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 /* - * 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/`. + * 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. * - * 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. + * 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. */ -/** Everything a card shows. [durationSeconds] is null until the player has probed the file. */ +/** What a card shows about the file. All of it is known from the event, before the player loads anything. */ @Immutable -class AudioCardUi( - val url: String, - val mimeType: String?, - val title: String, - val artist: String, +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?, - val durationSeconds: Int?, - val positionSeconds: Int, - val isPlaying: Boolean, - /** The blob's sha256 (`x`): seeds the synthetic waveform and the generated cover, so both are stable per file. */ + /** 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, or one decoded from the audio), 0..1. */ + /** The file's own amplitude envelope (a `waveform` tag), any scale. Null draws the synthetic one. */ val waveform: List? = null, - /** True when the imeta `image` or an embedded cover gives the file artwork. */ - val hasArtwork: Boolean = false, + /** The imeta `image`: the artwork [AudioCoverCard] shows. */ + val artworkUrl: String? = null, ) { - val progress: Float - get() = durationSeconds?.takeIf { it > 0 }?.let { (positionSeconds.toFloat() / it).coerceIn(0f, 1f) } ?: 0f - /** "MP3 · 4.8 MB" — known from the imeta alone. */ val fileFacts: String get() = listOfNotNull(format, sizeBytes?.let(::formatFileSize)).joinToString(" · ") - - /** "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 - - val layout: PlayableLayout - get() = playableLayout(mimeType, url, hasArtwork) } -/** The one entry point a post would call: picks the card from [AudioCardUi.layout]. */ -@Composable -fun PlayablePostMediaPrototype( - media: AudioCardUi, - modifier: Modifier = Modifier, +/** 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, ) { - 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), - ) + 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, + overlay: @Composable BoxScope.() -> Unit = {}, +) { + when (layout) { + PlayableLayout.AUDIO_WAVEFORM -> AudioWaveformCard(info, playback, onPlayPause, onSeek, modifier, onClick, overlay) + PlayableLayout.AUDIO_COVER -> AudioCoverCard(info, playback, onPlayPause, onSeek, modifier, onClick, overlay) + PlayableLayout.UNDECIDED -> UndecidedMediaCard(info, playback, onPlayPause, modifier, onClick, overlay) + PlayableLayout.VIDEO -> Unit } } // --------------------------------------------------------------------------------------------- -// B. Waveform scrubber — known audio. The waveform IS the seek bar. +// Known audio: the waveform is the seek bar. // --------------------------------------------------------------------------------------------- @Composable -fun AudioWaveformCardPrototype( - audio: AudioCardUi, +fun AudioWaveformCard( + info: AudioCardInfo, + playback: AudioPlaybackUi, + onPlayPause: () -> Unit, + onSeek: (Float) -> Unit, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + overlay: @Composable BoxScope.() -> Unit = {}, ) { - val bars = rememberBars(audio) - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, + 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(audio.isPlaying, size = 44.dp) + PlayPauseButton(playback.isPlaying, size = 44.dp, onClick = onPlayPause) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { WaveformBars( bars = bars, - progress = audio.progress, + 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( - audio.durationSeconds?.let { formatClock(audio.positionSeconds) } ?: audio.title, + if (playback.durationMs != 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( - listOfNotNull(audio.fileFacts, audio.durationSeconds?.let(::formatClock)).joinToString(" · "), + trailingFacts(info, playback), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, ) } } } + overlay() } } // --------------------------------------------------------------------------------------------- -// 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. +// Known audio with artwork: the artwork, with the same scrubber over its foot. // --------------------------------------------------------------------------------------------- @Composable -fun AudioCoverCardPrototype( - audio: AudioCardUi, +fun AudioCoverCard( + info: AudioCardInfo, + playback: AudioPlaybackUi, + onPlayPause: () -> Unit, + onSeek: (Float) -> Unit, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + overlay: @Composable BoxScope.() -> Unit = {}, ) { - val bars = rememberBars(audio) + val bars = rememberBars(info) Box( modifier = modifier .fillMaxWidth() .heightIn(max = 400.dp) .aspectRatio(1f) - .clip(RoundedCornerShape(16.dp)), + .clip(RoundedCornerShape(16.dp)) + .clickableWithoutRipple(onClick), ) { - GeneratedCover(audio.seed, Modifier.fillMaxSize(), icon = null, record = true) + // 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( @@ -222,62 +261,68 @@ fun AudioCoverCardPrototype( Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text( - audio.title, + audioTitle(info), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis, ) - Text( - audio.artist, - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.8f), - maxLines = 1, - ) + 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(audio.isPlaying, size = 52.dp, container = Color.White, content = Color.Black) + PlayPauseButton(playback.isPlaying, size = 52.dp, onClick = onPlayPause, container = Color.White, content = Color.Black) } Spacer(Modifier.height(12.dp)) WaveformBars( bars = bars, - progress = audio.progress, + 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( - audio.durationSeconds?.let { formatClock(audio.positionSeconds) } ?: "", + playback.durationMs?.let { formatClock(playback.positionMs) } ?: "", style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f), ) Text( - listOfNotNull(audio.fileFacts, audio.durationSeconds?.let(::formatClock)).joinToString(" · "), + trailingFacts(info, playback), style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f), + maxLines = 1, ) } } + overlay() } } // --------------------------------------------------------------------------------------------- -// 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. +// 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 UndecidedMediaCardPrototype( - media: AudioCardUi, +fun UndecidedMediaCard( + info: AudioCardInfo, + playback: AudioPlaybackUi, + onPlayPause: () -> Unit, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + overlay: @Composable BoxScope.() -> Unit = {}, ) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, + Box( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickableWithoutRipple(onClick), ) { Column { Row( @@ -285,7 +330,10 @@ fun UndecidedMediaCardPrototype( verticalAlignment = Alignment.CenterVertically, ) { Box( - Modifier.size(64.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), + 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) @@ -293,66 +341,29 @@ fun UndecidedMediaCardPrototype( Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Text( - media.title, + info.title ?: stringRes(Res.string.playable_media_untitled), 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, - ) + info.artist?.let { + Text(it, 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, + trailingFacts(info, playback), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) } Spacer(Modifier.width(8.dp)) - PlayPauseButton(media.isPlaying, size = 48.dp) + PlayPauseButton(playback.isPlaying, size = 48.dp, onClick = onPlayPause) } - ThinProgress(media.progress, Modifier.fillMaxWidth().height(3.dp)) - } - } -} - -// --------------------------------------------------------------------------------------------- -// E. Mini player — where a playing track goes once its post scrolls away. Complements B and C. -// --------------------------------------------------------------------------------------------- - -@Composable -fun AudioMiniPlayerPrototype( - audio: AudioCardUi, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shadowElevation = 6.dp, - ) { - Column { - Row( - modifier = Modifier.padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - 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) - Text(audio.artist, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - PlayPauseButton(audio.isPlaying, size = 40.dp, container = Color.Transparent, content = MaterialTheme.colorScheme.onSurface) - Icon(MaterialSymbols.Close, stringRes(Res.string.close), Modifier.padding(horizontal = 8.dp).size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - ThinProgress(audio.progress, Modifier.fillMaxWidth().height(2.dp)) + ThinProgress(playback.progress, Modifier.fillMaxWidth().height(3.dp)) } + overlay() } } @@ -362,18 +373,51 @@ fun AudioMiniPlayerPrototype( /** 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 } +private fun rememberBars(info: AudioCardInfo): List = 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): List? { + 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?.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 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), + modifier = + modifier + .size(size) + .clip(CircleShape) + .background(container) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), contentAlignment = Alignment.Center, ) { Icon( @@ -436,7 +480,10 @@ private fun ThinProgress( } } -/** Centred bars, coloured [played] up to [progress] and [unplayed] after it. */ +/** + * 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, @@ -446,8 +493,36 @@ fun WaveformBars( modifier: Modifier = Modifier, barWidth: Dp = 3.dp, gap: Dp = 2.dp, + onSeek: ((Float) -> Unit)? = null, ) { - Canvas(modifier) { + var dragFraction by remember { mutableStateOf(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() @@ -456,7 +531,7 @@ fun WaveformBars( val h = amp * size.height val x = i * step drawRoundRect( - color = if (x / size.width < progress) played else unplayed, + 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), @@ -472,7 +547,13 @@ fun seedColors(seed: String): Pair { return Color.hsv(hue, 0.6f, 0.62f) to Color.hsv((hue + 48f) % 360f, 0.75f, 0.32f) } -fun formatClock(seconds: Int): String = "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}" +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() 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/PlayableMediaCardsRenderTest.kt similarity index 90% rename from commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/AudioPlayerPrototypesRenderTest.kt rename to commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardsRenderTest.kt index d2822b3daf..b0baa0c130 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/PlayableMediaCardsRenderTest.kt @@ -29,12 +29,12 @@ import kotlin.test.Test import kotlin.test.assertTrue /** - * Renders every audio-player prototype offscreen (no device, no display) through `ImageComposeScene`, in the + * 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/.png`, * so the candidate designs for audio inside a post can be compared as screenshots. Fails if a screen throws * or draws nothing. */ -class AudioPlayerPrototypesRenderTest { +class PlayableMediaCardsRenderTest { private val outDir = File("build/audio-player").apply { mkdirs() } private fun render( @@ -66,16 +66,12 @@ class AudioPlayerPrototypesRenderTest { } } - @Test fun today() = render("00-today", 820, 700) { AudioTodayPreview() } - - @Test fun knownAudio() = render("01-known-audio-waveform", 820, 560) { AudioKnownAudioPreview() } + @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 miniPlayer() = render("04-mini-player", 820, 520) { AudioMiniPlayerPreview() } - private companion object { const val SETTLE_FRAMES = 12 const val FRAME_MILLIS = 60L From 604f5747fffd4096cd7d9a3adc14a1e1f37c9ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:46:29 +0000 Subject: [PATCH 4/5] feat(audio): picture-in-picture for long audio in posts Audio cards for tracks of ten minutes or more (by the player, else the imeta duration) show a picture-in-picture button; the overlay's PiP button does the same for any audio. Both pause the inline player and hand the same pooled player to the PiP window, which resumes from there. For audio, the PiP window is now square and draws the cover (imeta image, else the generated one), the waveform as progress and the time, instead of an empty video surface. It starts unmuted: the feed's mute default is for silently autoplaying video. MediaItemData gains an isAudio flag, and it and the hash survive the PiP intent. MediaUrlVideo carries the imeta duration, so the card shows the length and offers PiP before the player has probed the file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE --- .../composable/AudioPlaybackUiState.kt | 76 +++++++++ .../composable/mediaitem/MediaItemData.kt | 3 + .../service/playback/pip/IntentExtras.kt | 4 + .../service/playback/pip/PipAudioView.kt | 58 +++++++ .../service/playback/pip/PipVideoActivity.kt | 12 +- .../ui/components/PlayableAudioView.kt | 150 +++++++++--------- .../commons/richtext/MediaContentModels.kt | 3 + .../commons/richtext/RichTextParser.kt | 2 + .../richtext/RichTextParserAudioImetaTest.kt | 8 + .../audio/player/PlayableMediaCardPreviews.kt | 39 ++++- .../audio/player/PlayableMediaCards.kt | 108 ++++++++++++- .../player/PlayableMediaCardsRenderTest.kt | 2 + 12 files changed, 385 insertions(+), 80 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlaybackUiState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipAudioView.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlaybackUiState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlaybackUiState.kt new file mode 100644 index 0000000000..1623954bc1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/AudioPlaybackUiState.kt @@ -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 { + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt index 7ff0500dae..6c3604c680 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt @@ -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 = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt index 53f1ddd1b9..43728e73e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipAudioView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipAudioView.kt new file mode 100644 index 0000000000..3899f27e72 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipAudioView.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 6998148f91..8c9aaa13d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -46,8 +46,10 @@ class PipVideoActivity : ComponentActivity() { setContent { val videoData by rememberVideoDataFromIntents() videoData?.let { mediaItemData -> - // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes - val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value } + // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes. + // Audio is never started muted: the feed's mute default is about silent autoplaying + // video, and an audio track somebody just sent to PiP is meant to be heard. + val muted = remember(mediaItemData) { !mediaItemData.isAudio && DEFAULT_MUTED_SETTING.value } GetMediaItem(mediaItemData) { mediaItem -> GetVideoController( @@ -60,7 +62,11 @@ class PipVideoActivity : ComponentActivity() { RegisterBackgroundMedia(controllerState) RegisterControllerReceiver(controllerState) WatchControllerForActions(mediaItemData, controllerState) - RenderPipVideo(controllerState, mediaItemData.waveformData) + if (mediaItemData.isAudio) { + RenderPipAudio(controllerState, mediaItemData) + } else { + RenderPipVideo(controllerState, mediaItemData.waveformData) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt index 14d543062d..c3ffb0559f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/PlayableAudioView.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.content.pm.PackageManager +import android.graphics.Rect import androidx.annotation.OptIn import androidx.collection.LruCache import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,16 +37,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util import com.vitorpamplona.amethyst.commons.audio.PlayableLayout +import com.vitorpamplona.amethyst.commons.audio.WaveformData import com.vitorpamplona.amethyst.commons.audio.mediaFormatLabel import com.vitorpamplona.amethyst.commons.audio.player.AudioCardInfo import com.vitorpamplona.amethyst.commons.audio.player.AudioPlaybackUi import com.vitorpamplona.amethyst.commons.audio.player.PlayableMediaCard +import com.vitorpamplona.amethyst.commons.audio.syntheticWaveformFor import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled_no_format @@ -53,13 +57,15 @@ import com.vitorpamplona.amethyst.commons.resources.playable_media_untitled import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem -import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData +import com.vitorpamplona.amethyst.service.playback.composable.rememberAudioPlaybackUi +import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity import com.vitorpamplona.amethyst.ui.note.types.RenderTopButtonsForVoice -import kotlinx.coroutines.delay /** * What the player found when it probed a URL: true when every track is audio, false when there is a @@ -79,6 +85,9 @@ object PlayableMediaProbeCache { } } +/** Audio at least this long gets a picture-in-picture button on its card: it will outlast the post on screen. */ +private const val LONG_AUDIO_MS = 10 * 60 * 1000L + /** * A playable file inside a post that is (or may be) audio: the shared [PlayableMediaCard] for [layout], * driven by the same pooled player as every other inline media. @@ -88,6 +97,9 @@ object PlayableMediaProbeCache { * fetched until play is pressed. Once the player knows the tracks, [onProbed] reports whether they are * all audio, which is how an undecided file finds its real card and how a mislabelled video leaves for * the video player. + * + * Known audio that runs [LONG_AUDIO_MS] or more (by the player, else the imeta `duration`) shows a + * picture-in-picture button, so a podcast can keep playing in a floating window while the feed scrolls. */ @Composable fun PlayableAudioView( @@ -96,6 +108,7 @@ fun PlayableAudioView( onProbed: (audioOnly: Boolean) -> Unit, accountViewModel: AccountViewModel, ) { + val context = LocalContext.current val authorName = remember(content.authorName, content.authorPubKey) { content.authorName ?: content.authorPubKey?.let { accountViewModel.getUserIfExists(it)?.toBestDisplayName() } @@ -111,6 +124,7 @@ fun PlayableAudioView( seed = content.hash ?: content.url, waveform = content.waveform, artworkUrl = content.artworkUri, + declaredDurationMs = content.durationSeconds?.let { (it * 1000).toLong() }, ) } @@ -122,33 +136,56 @@ fun PlayableAudioView( else -> stringRes(Res.string.audio_card_untitled_no_format) } + val mediaData = + remember(content, mediaTitle, authorName) { + MediaItemData( + videoUri = content.url, + authorName = authorName, + title = mediaTitle, + artworkUri = content.artworkUri, + callbackUri = content.uri, + mimeType = content.mimeType, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(content.url), + keepPlaying = false, + isLiveStream = content.isLiveStream, + hash = content.hash, + ) + } + var load by remember(content.url) { mutableStateOf(accountViewModel.settings.startVideoPlayback()) } var playWhenConnected by remember(content.url) { mutableStateOf(false) } - var active by remember(content.url) { mutableStateOf?>(null) } + var active by remember(content.url) { mutableStateOf(null) } val controllerVisible = remember(content.url) { mutableStateOf(false) } + // Where picture-in-picture animates from. + val bounds = remember(content.url) { arrayOf(null) } + val pipSupported = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } + + // Hands playback to the PiP window: it attaches to the same pooled player (same URL), so pausing + // here first keeps the two from playing over each other, and it resumes from this position. + val startPictureInPicture = { + active?.controller?.pause() + PipVideoActivity.callIn( + mediaData.copy( + // Square window, and the audio view instead of an empty video surface. + aspectRatio = 1f, + isAudio = true, + waveformData = WaveformData(info.waveform ?: syntheticWaveformFor(info.seed).wave), + ), + bounds[0], + context.getActivity(), + ) + } if (load) { - val proxyPort = remember(content.url) { accountViewModel.httpClientBuilder.proxyPortForVideo(content.url) } - GetMediaItem( - videoUri = content.url, - title = mediaTitle, - artworkUri = content.artworkUri, - authorName = authorName, - callbackUri = content.uri, - mimeType = content.mimeType, - proxyPort = proxyPort, - keepPlaying = false, - isLiveStream = content.isLiveStream, - hash = content.hash, - ) { mediaItem -> + GetMediaItem(mediaData) { mediaItem -> GetVideoController(mediaItem = mediaItem, muted = false) { controller -> PauseControllerWhenInBackground(controller) ReportProbe(controller.controller, onProbed) - DisposableEffect(controller, mediaItem) { - active = controller to mediaItem + DisposableEffect(controller) { + active = controller onDispose { - if (active?.first === controller) active = null + if (active === controller) active = null } } @@ -163,11 +200,17 @@ fun PlayableAudioView( } } + val trackBounds = + Modifier.onGloballyPositioned { coordinates -> + val b = coordinates.boundsInWindow() + bounds[0] = Rect(b.left.toInt(), b.top.toInt(), b.right.toInt(), b.bottom.toInt()) + } + Box { - val current = active - if (current != null) { - val (controller, mediaItem) = current + val controller = active + if (controller != null) { val playback by rememberAudioPlaybackUi(controller.controller) + val totalMs = playback.durationMs ?: info.declaredDurationMs PlayableMediaCard( layout = layout, info = info, @@ -175,23 +218,25 @@ fun PlayableAudioView( onPlayPause = { playOrPause(controller.controller) }, onSeek = { fraction -> playback.durationMs?.let { controller.controller.seekTo((it * fraction).toLong()) } }, onClick = { controllerVisible.value = !controllerVisible.value }, - modifier = - Modifier.onGloballyPositioned { coordinates -> - // Where picture-in-picture animates from. - val bounds = coordinates.boundsInWindow() - controller.visibility.setBounds(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) - }, + onPictureInPicture = startPictureInPicture.takeIf { pipSupported && layout != PlayableLayout.UNDECIDED && totalMs != null && totalMs >= LONG_AUDIO_MS }, + modifier = trackBounds, overlay = { RenderTopButtonsForVoice( - mediaData = mediaItem.src, - controllerState = controller, + mediaData = mediaData, controllerVisible = controllerVisible, + startingMuteState = controller.controller.volume < 0.001, + onMuteClick = { mute -> + DEFAULT_MUTED_SETTING.value = mute + controller.controller.volume = if (mute) 0f else 1f + }, + onPictureInPictureClick = startPictureInPicture, modifier = Modifier.align(Alignment.TopEnd), accountViewModel = accountViewModel, ) }, ) } else { + val totalMs = info.declaredDurationMs PlayableMediaCard( layout = layout, info = info, @@ -201,6 +246,8 @@ fun PlayableAudioView( load = true }, onSeek = {}, + onPictureInPicture = startPictureInPicture.takeIf { pipSupported && layout != PlayableLayout.UNDECIDED && totalMs != null && totalMs >= LONG_AUDIO_MS }, + modifier = trackBounds, ) } } @@ -237,44 +284,3 @@ private fun ReportProbe( onDispose { player.removeListener(listener) } } } - -/** The player's state as the card draws it: event-driven, plus a position poll while it plays. */ -@Composable -private fun rememberAudioPlaybackUi(player: Player): State { - 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 6148d22d99..63f68c89a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -122,6 +122,9 @@ open class MediaUrlVideo( val sizeBytes: Long? = null, // The imeta `waveform` (NIP-A0 amplitudes, not normalised): an audio card draws it as its seek bar. val waveform: List? = 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 63d1a009cb..b4731798df 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList @@ -99,6 +100,7 @@ class RichTextParser { authorPubKey = authorPubKey, sizeBytes = (frags[SizeTag.TAG_NAME] ?: tags[SizeTag.TAG_NAME]?.firstOrNull())?.toLongOrNull(), waveform = (frags[WaveformTag.TAG_NAME] ?: tags[WaveformTag.TAG_NAME]?.firstOrNull())?.let { WaveformTag.parseWave(it) }, + durationSeconds = (frags[DurationTag.TAG_NAME] ?: tags[DurationTag.TAG_NAME]?.firstOrNull())?.toDoubleOrNull()?.takeIf { it > 0 }, ) } else if (kind == MediaContentKind.PDF) { MediaUrlPdf( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt index 121a6d2dbb..3af55ab51c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserAudioImetaTest.kt @@ -49,6 +49,14 @@ class RichTextParserAudioImetaTest { assertEquals("audio/mpeg", media.mimeType) assertEquals(4_992_768L, media.sizeBytes) assertNull(media.waveform) + assertNull(media.durationSeconds) + } + + @Test + fun durationReachesTheMedia() { + val media = parse("m audio/mpeg", "duration 3725.4") + assertIs(media) + assertEquals(3725.4, media.durationSeconds) } @Test diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt index d3e3e38bc4..a85d9edce0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardPreviews.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -86,7 +87,19 @@ object AudioPlayerSamples { /** An HLS playlist: audio only or video, nothing tells until the player probes it. */ val stream = AudioCardInfo(title = null, artist = AUTHOR_NAME, format = "HLS", sizeBytes = null, seed = HLS_URL) + /** A 1h12 podcast episode: long enough that the card offers picture-in-picture. */ + val podcast = + AudioCardInfo( + title = null, + artist = AUTHOR_NAME, + format = "MP3", + sizeBytes = 69_206_016, + seed = "a7d93c0e5b2f4816", + declaredDurationMs = 4_325_000, + ) + val playing = AudioPlaybackUi(isPlaying = true, positionMs = 72_000, durationMs = 208_000) + val podcastPlaying = AudioPlaybackUi(isPlaying = true, positionMs = 1_534_000, durationMs = 4_325_000) val pausedEarly = AudioPlaybackUi(isPlaying = false, positionMs = 9_000, durationMs = 41_000) } @@ -139,7 +152,8 @@ private fun Card( layout: PlayableLayout, info: AudioCardInfo, playback: AudioPlaybackUi = AudioPlaybackUi.Idle, -) = PlayableMediaCard(layout, info, playback, onPlayPause = {}, onSeek = {}) + pip: Boolean = false, +) = PlayableMediaCard(layout, info, playback, onPlayPause = {}, onSeek = {}, onPictureInPicture = if (pip) ({}) else null) @Preview(widthDp = 820, heightDp = 760) @Composable @@ -175,3 +189,26 @@ fun AudioUndecidedPreview() = MockAudioPost(text = "Live set tonight, tune in") { Card(playableLayout(null, AudioPlayerSamples.HLS_URL, hasArtwork = false), AudioPlayerSamples.stream) } } } + +@Preview(widthDp = 820, heightDp = 1000) +@Composable +fun AudioPictureInPicturePreview() = + AudioPreviewFrame { + val waveform = playableLayout("audio/mpeg", AudioPlayerSamples.MP3_URL, hasArtwork = false) + val cover = playableLayout("audio/mpeg", AudioPlayerSamples.MP3_URL, hasArtwork = true) + Column { + Caption("Long audio offers picture-in-picture (before load: imeta duration)") + MockAudioPost(text = "New episode is out!") { Card(waveform, AudioPlayerSamples.podcast, pip = true) } + MockAudioPost(text = "New episode is out!") { Card(cover, AudioPlayerSamples.podcast, AudioPlayerSamples.podcastPlaying, pip = true) } + Caption("The PiP window") + Row(Modifier.padding(12.dp)) { + Box(Modifier.size(180.dp).clip(RoundedCornerShape(12.dp))) { + AudioPipContent(AudioPlayerSamples.podcast, AudioPlayerSamples.podcastPlaying) + } + Spacer(Modifier.width(12.dp)) + Box(Modifier.size(180.dp).clip(RoundedCornerShape(12.dp))) { + AudioPipContent(AudioPlayerSamples.withArtwork, AudioPlayerSamples.playing) + } + } + } + } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCards.kt index 26b654cf58..dfa5e70e4b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCards.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCards.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled import com.vitorpamplona.amethyst.commons.resources.audio_card_untitled_no_format import com.vitorpamplona.amethyst.commons.resources.pause +import com.vitorpamplona.amethyst.commons.resources.picture_in_picture import com.vitorpamplona.amethyst.commons.resources.play import com.vitorpamplona.amethyst.commons.resources.playable_media_untitled import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -111,6 +112,8 @@ class AudioCardInfo( val waveform: List? = null, /** The imeta `image`: the artwork [AudioCoverCard] shows. */ val artworkUrl: String? = null, + /** The imeta `duration`: shown until the player knows the real one. */ + val declaredDurationMs: Long? = null, ) { /** "MP3 · 4.8 MB" — known from the imeta alone. */ val fileFacts: String @@ -142,11 +145,12 @@ fun PlayableMediaCard( onSeek: (Float) -> Unit, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + onPictureInPicture: (() -> Unit)? = null, overlay: @Composable BoxScope.() -> Unit = {}, ) { when (layout) { - PlayableLayout.AUDIO_WAVEFORM -> AudioWaveformCard(info, playback, onPlayPause, onSeek, modifier, onClick, overlay) - PlayableLayout.AUDIO_COVER -> AudioCoverCard(info, playback, onPlayPause, onSeek, modifier, onClick, overlay) + PlayableLayout.AUDIO_WAVEFORM -> AudioWaveformCard(info, playback, onPlayPause, onSeek, modifier, onClick, onPictureInPicture, overlay) + PlayableLayout.AUDIO_COVER -> AudioCoverCard(info, playback, onPlayPause, onSeek, modifier, onClick, onPictureInPicture, overlay) PlayableLayout.UNDECIDED -> UndecidedMediaCard(info, playback, onPlayPause, modifier, onClick, overlay) PlayableLayout.VIDEO -> Unit } @@ -164,6 +168,8 @@ fun AudioWaveformCard( onSeek: (Float) -> Unit, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + /** Non-null shows the picture-in-picture button: meant for long audio, where the post will scroll away. */ + onPictureInPicture: (() -> Unit)? = null, overlay: @Composable BoxScope.() -> Unit = {}, ) { val bars = rememberBars(info) @@ -192,7 +198,7 @@ fun AudioWaveformCard( Spacer(Modifier.height(4.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - if (playback.durationMs != null) formatClock(playback.positionMs) else audioTitle(info), + if (playback.durationMs != null || info.declaredDurationMs != null) formatClock(playback.positionMs) else audioTitle(info), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -208,6 +214,10 @@ fun AudioWaveformCard( ) } } + if (onPictureInPicture != null) { + Spacer(Modifier.width(4.dp)) + PictureInPictureButton(onPictureInPicture, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } } overlay() } @@ -225,6 +235,8 @@ fun AudioCoverCard( onSeek: (Float) -> Unit, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + /** Non-null shows the picture-in-picture button: meant for long audio, where the post will scroll away. */ + onPictureInPicture: (() -> Unit)? = null, overlay: @Composable BoxScope.() -> Unit = {}, ) { val bars = rememberBars(info) @@ -299,10 +311,84 @@ fun AudioCoverCard( ) } } + if (onPictureInPicture != null) { + PictureInPictureButton( + onPictureInPicture, + tint = Color.White, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)), + ) + } overlay() } } +// --------------------------------------------------------------------------------------------- +// Picture-in-picture: what the small floating window shows for audio. Square, display-only (a PiP +// window takes no touches; the system draws play/pause over it), the cover behind the waveform. +// --------------------------------------------------------------------------------------------- + +@Composable +fun AudioPipContent( + info: AudioCardInfo, + playback: AudioPlaybackUi, + modifier: Modifier = Modifier, +) { + val bars = rememberBars(info) + Box(modifier.fillMaxSize()) { + GeneratedCover(info.seed, Modifier.fillMaxSize(), icon = null, record = info.artworkUrl == null) + if (info.artworkUrl != null) { + AsyncImage( + model = info.artworkUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier + .fillMaxWidth() + .fillMaxHeight(0.6f) + .align(Alignment.BottomCenter) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f)))), + ) + Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(10.dp)) { + Text( + audioTitle(info), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + info.artist?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Spacer(Modifier.height(6.dp)) + WaveformBars( + bars = bars, + progress = playback.progress, + played = Color.White, + unplayed = Color.White.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth().height(24.dp), + barWidth = 2.dp, + gap = 2.dp, + ) + Spacer(Modifier.height(2.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatClock(playback.positionMs), style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f)) + (playback.durationMs ?: info.declaredDurationMs)?.let { + Text(formatClock(it), style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.8f)) + } + } + } + } +} + // --------------------------------------------------------------------------------------------- // Audio or video, can't tell yet: commits to neither shape (no waveform, no picture box) and gives way // to the waveform, the cover or the video player once the probe answers. @@ -393,7 +479,7 @@ private fun audioTitle(info: AudioCardInfo): String = private fun trailingFacts( info: AudioCardInfo, playback: AudioPlaybackUi, -): String = listOfNotNull(info.fileFacts.ifEmpty { null }, playback.durationMs?.let(::formatClock)).joinToString(" · ") +): String = listOfNotNull(info.fileFacts.ifEmpty { null }, (playback.durationMs ?: info.declaredDurationMs)?.let(::formatClock)).joinToString(" · ") private fun Modifier.clickableWithoutRipple(onClick: (() -> Unit)?): Modifier = if (onClick == null) { @@ -402,6 +488,20 @@ private fun Modifier.clickableWithoutRipple(onClick: (() -> Unit)?): Modifier = this.then(Modifier.clickable(interactionSource = null, indication = null, onClick = onClick)) } +@Composable +private fun PictureInPictureButton( + onClick: () -> Unit, + tint: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.size(36.dp).clip(CircleShape).clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.PictureInPicture, stringRes(Res.string.picture_in_picture), Modifier.size(22.dp), tint = tint) + } +} + @Composable fun PlayPauseButton( isPlaying: Boolean, diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardsRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardsRenderTest.kt index b0baa0c130..c9d13b11e4 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardsRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/audio/player/PlayableMediaCardsRenderTest.kt @@ -72,6 +72,8 @@ class PlayableMediaCardsRenderTest { @Test fun undecided() = render("03-undecided-track-card", 820, 360) { AudioUndecidedPreview() } + @Test fun pictureInPicture() = render("04-picture-in-picture", 820, 1000) { AudioPictureInPicturePreview() } + private companion object { const val SETTLE_FRAMES = 12 const val FRAME_MILLIS = 60L From ae55ae262f1e4b41c787da6d9786ae5ff0f5a70e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:41:33 +0000 Subject: [PATCH 5/5] test: let the search-parity corpus past the anti-spam filter The fixture re-recorded in #4299 holds the same long-form article posted by two authors. LocalCache's anti-spam filter keeps the second copy out, so anAddressableKindComesBackThroughTheAddressablePath could never find it and main went red. The class tests the filter path, not spam policy: switch the filter off while loading the corpus and restore it after, since LocalCache is shared with the other test classes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE --- .../amethyst/model/LocalCacheSearchParityTest.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt index 08e2c30f86..ddfb67e797 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/LocalCacheSearchParityTest.kt @@ -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. - corpus.forEach { LocalCache.justConsumeMyOwnEvent(it) } + // + // 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 + } } }