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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE
This commit is contained in:
Claude
2026-10-01 13:16:51 +00:00
parent 2d8fdb85a4
commit 949cd45798
3 changed files with 889 additions and 0 deletions
@@ -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) }
}
}
@@ -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<Float>? = 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<Float>,
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<Color, Color> {
val n = seed.take(6).toIntOrNull(16) ?: seed.hashCode()
val hue = (abs(n) % 360).toFloat()
return Color.hsv(hue, 0.6f, 0.62f) to Color.hsv((hue + 48f) % 360f, 0.75f, 0.32f)
}
/** A music-looking envelope generated from the hash: the stand-in until a real waveform exists. */
fun placeholderBars(
seed: String,
count: Int,
): List<Float> {
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"
}
@@ -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/<name>.png`,
* so the candidate designs for audio inside a post can be compared as screenshots. Fails if a screen throws
* or draws nothing.
*/
class 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<Int>()
for (y in 0 until height step 7) for (x in 0 until width step 7) distinct += pixels.getColor(x, y)
assertTrue(distinct.size > MIN_COLOURS, "$name rendered almost nothing (${distinct.size} colours)")
} finally {
scene.close()
}
}
@Test fun 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
}
}