mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
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:
+66
@@ -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.5–8 full cycles across the strip
|
||||
val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.18–0.36
|
||||
val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.28–0.50
|
||||
val envelopeStrength = rng.nextFloat() * 0.45f // 0.0–0.45; some fade in/out, others don't
|
||||
val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.18–0.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.5–8 full cycles across the strip
|
||||
val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.18–0.36
|
||||
val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.28–0.50
|
||||
val envelopeStrength = rng.nextFloat() * 0.45f // 0.0–0.45; some tracks fade in/out, others don't
|
||||
val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.18–0.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.
|
||||
|
||||
+14
-8
@@ -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,
|
||||
)
|
||||
|
||||
+25
-23
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+6
-5
@@ -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() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user