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 + } +}