diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt new file mode 100644 index 0000000000..a9f2a26726 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 3b20bba162..019f30c700 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 3f8361581c..51cde34882 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -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 + 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 7f7f3efd10..35f90c6444 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -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 }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index d6bea463dc..af359a87f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -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() } }