feat(podcasts): translatable show description + synthetic playback waveform

- The show description on the podcast screen now renders through
  TranslatableRichTextViewer (rich text + translation), matching how a
  profile's About is shown, instead of a plain Text. PodcastHeader takes a
  nav for the rich content's links.
- Audio playback now shows a decorative per-episode waveform when the
  source has none, instead of a flat strip — same behaviour as music
  tracks. Extracted MusicTrack's private syntheticWaveformFor into a shared
  wavefront/SyntheticWaveform.kt used by both music and podcast players; the
  podcast player still prefers a real IMeta waveform when one is present.
This commit is contained in:
Claude
2026-05-29 21:21:54 +00:00
parent 07855c2240
commit 063ebbebce
5 changed files with 112 additions and 77 deletions
@@ -0,0 +1,66 @@
/*
* 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.wavefront
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import kotlin.math.sin
import kotlin.random.Random
private const val SYNTHETIC_WAVEFORM_SAMPLES = 96
private const val TWO_PI = (Math.PI * 2).toFloat()
/**
* Builds a stable, decorative waveform from a [seed] (usually an event id). ExoPlayer drives
* actual playback progress — these bars only exist so an audio player has the familiar
* "audio silhouette" shape rather than a flat strip when the source carries no `waveform` tag.
* Shared by every audio renderer whose spec omits waveforms (music tracks, podcast episodes, …).
*
* Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter
* envelope) is drawn from the seeded RNG before the per-bar loop, so two different seeds
* produce visibly different waveforms — earlier versions used a fixed envelope + carrier that
* only varied by per-bar noise, which made every track look like the same slow-fade sine with
* minor wiggle.
*/
fun syntheticWaveformFor(seed: String): WaveformData {
// Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide
// (rare for hex addresses but cheap to defend against) still differ via the bit shuffle.
val rng = Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong())
// Shape parameters drawn once per seed — these are what makes seed A look different
// from seed B at a glance.
val phaseOffset = rng.nextFloat() * TWO_PI
val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.58 full cycles across the strip
val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.180.36
val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.280.50
val envelopeStrength = rng.nextFloat() * 0.45f // 0.00.45; some fade in/out, others don't
val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.180.40
val bars =
List(SYNTHETIC_WAVEFORM_SAMPLES) { index ->
val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES
// Optional gentle fade so some taper at the ends, others stay even.
val envelope = 1f - envelopeStrength * (1f - sin(phase * Math.PI).toFloat())
val carrier = sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight
val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength
((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f)
}
return WaveformData(bars)
}
@@ -62,8 +62,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView
import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -438,46 +438,6 @@ private fun TopicChip(
)
}
private const val SYNTHETIC_WAVEFORM_SAMPLES = 96
private const val TWO_PI = (Math.PI * 2).toFloat()
/**
* Builds a stable, decorative waveform from the track id. ExoPlayer drives actual playback
* progress — these bars only exist so the audio player has the familiar "music silhouette"
* shape rather than a flat strip when the spec has no `waveform` tag.
*
* Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter
* envelope) is drawn from the seeded RNG before the per-bar loop, so two different track ids
* produce visibly different waveforms — earlier versions used a fixed envelope + carrier that
* only varied by per-bar noise, which made every track look like the same slow-fade sine with
* minor wiggle.
*/
private fun syntheticWaveformFor(seed: String): WaveformData {
// Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide
// (rare for hex addresses but cheap to defend against) still differ via the bit shuffle.
val rng = kotlin.random.Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong())
// Shape parameters drawn once per track — these are what makes track A look different
// from track B at a glance.
val phaseOffset = rng.nextFloat() * TWO_PI
val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.58 full cycles across the strip
val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.180.36
val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.280.50
val envelopeStrength = rng.nextFloat() * 0.45f // 0.00.45; some tracks fade in/out, others don't
val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.180.40
val bars =
List(SYNTHETIC_WAVEFORM_SAMPLES) { index ->
val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES
// Optional gentle fade so some tracks taper at the ends, others stay even.
val envelope = 1f - envelopeStrength * (1f - kotlin.math.sin(phase * Math.PI).toFloat())
val carrier = kotlin.math.sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight
val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength
((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f)
}
return WaveformData(bars)
}
// ---------------------------------------------------------------------------
// @Preview composables. These wire a constructed MusicTrackEvent through
// LocalCache so the renderer pulls the real Note path it would in production.
@@ -33,15 +33,10 @@ import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
// NIP-F4 episodes carry no waveform tag, so every episode renders with the same flat baseline.
// Hoisted to a top-level constant so we share one List<Float> + WaveformData across the whole
// feed instead of allocating a fresh 96-element list per visible card.
private const val WAVEFORM_SAMPLES = 96
private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f })
// The voice player's internal controls are laid out for 100.dp; 80.dp is the tightest height
// that still fits the play button without clipping it. Shared so the feed card and the
// per-podcast list row stay pixel-identical.
@@ -62,6 +57,17 @@ fun PodcastEpisodeAudioPlayer(
accountViewModel: AccountViewModel,
) {
val callbackUri = remember(note) { note.toNostrUri() }
// Use a real waveform when the episode ships one in its IMeta; NIP-F4 usually doesn't, so
// fall back to a decorative waveform seeded by the note id — same approach as music tracks,
// a per-episode silhouette instead of a flat strip.
val waveform =
remember(note) {
note.event
?.getAudioMetaWithWaveform()
?.waveform
?.let { WaveformData(it) }
?: syntheticWaveformFor(note.idHex)
}
Row(
PLAYER_HEIGHT_MODIFIER,
@@ -77,7 +83,7 @@ fun PodcastEpisodeAudioPlayer(
aspectRatio = null,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url),
keepPlaying = false,
waveformData = FLAT_WAVEFORM,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -87,7 +93,7 @@ fun PodcastEpisodeAudioPlayer(
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = FLAT_WAVEFORM,
waveform = waveform,
borderModifier = borderModifier,
accountViewModel = accountViewModel,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -31,17 +30,20 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.types.PodcastCoverCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -50,9 +52,9 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
/**
* Hero header for a single podcast screen: large cover art, title, websites and a tappable
* (collapse/expand) description, followed by the "Episodes (N)" section divider that the
* episode rows hang under.
* Hero header for a single podcast screen: large cover art, title, websites and the show
* description (rich text + translation, same as a profile's About), followed by the
* "Episodes (N)" section divider that the episode rows hang under.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -61,11 +63,13 @@ fun PodcastHeader(
metadataEvent: PodcastMetadataEvent?,
episodeCount: Int?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val title = remember(metadataEvent) { metadataEvent?.title() }
val image = remember(metadataEvent) { metadataEvent?.image() }
val description = remember(metadataEvent) { metadataEvent?.description() }
val websites = remember(metadataEvent) { metadataEvent?.websites() ?: emptyList() }
val tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column(Modifier.fillMaxWidth()) {
PodcastCoverCard(image, metadataNote, accountViewModel)
@@ -103,7 +107,21 @@ fun PodcastHeader(
}
}
description?.let { ExpandableDescription(it) }
description?.let {
val background = remember { mutableStateOf(Color.Transparent) }
TranslatableRichTextViewer(
content = it,
canPreview = false,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = background,
id = metadataNote.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
// Only render once episodes have actually loaded — avoids flashing "0 episodes"
@@ -123,19 +141,3 @@ fun PodcastHeader(
}
}
}
@Composable
private fun ExpandableDescription(description: String) {
var expanded by remember(description) { mutableStateOf(false) }
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (expanded) Int.MAX_VALUE else 4,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
)
}
@@ -147,17 +147,17 @@ private fun PodcastScreenBody(
PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav)
is FeedState.Empty ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) {
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
StatusText(stringRes(R.string.podcast_no_episodes))
}
is FeedState.FeedError ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) {
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
FeedError(state.errorMessage) { feedViewModel.invalidateData() }
}
is FeedState.Loading ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) {
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@@ -181,7 +181,7 @@ private fun PodcastEpisodesList(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel)
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav)
}
itemsIndexed(
@@ -204,6 +204,7 @@ private fun PodcastHeaderWithStatus(
metadataEvent: PodcastMetadataEvent?,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
status: @Composable () -> Unit,
) {
LazyColumn(
@@ -211,7 +212,7 @@ private fun PodcastHeaderWithStatus(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel)
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav)
}
item("status") { status() }
}