Merge pull request #3105 from vitorpamplona/claude/eager-davinci-WxjiM

Add dedicated podcast screen with episode list and inline player
This commit is contained in:
Vitor Pamplona
2026-05-29 17:32:55 -04:00
committed by GitHub
18 changed files with 1053 additions and 101 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)
}
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
@@ -133,6 +134,7 @@ class RelaySubscriptionsCoordinator(
val musicPlaylists = MusicPlaylistsFilterAssembler(client)
val podcastEpisodes = PodcastEpisodesFilterAssembler(client)
val podcasts = PodcastsFilterAssembler(client)
val onePodcast = OnePodcastFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
@@ -179,6 +181,7 @@ class RelaySubscriptionsCoordinator(
musicPlaylists,
podcastEpisodes,
podcasts,
onePodcast,
softwareApps,
badges,
profileBadges,
@@ -148,6 +148,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
@@ -297,6 +298,7 @@ fun BuildNavigation(
composableFromEnd<Route.MusicPlaylists> { MusicPlaylistsScreen(accountViewModel, nav) }
composableFromEnd<Route.PodcastEpisodes> { PodcastEpisodesScreen(accountViewModel, nav) }
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
@@ -162,6 +162,10 @@ sealed class Route {
@Serializable object Podcasts : Route()
@Serializable data class Podcast(
val pubkey: String,
) : Route()
@Serializable
data class NewMusicTrack(
val dTag: String? = null,
@@ -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.
@@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -33,7 +31,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.graphics.Color
@@ -42,10 +39,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
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.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -53,12 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
// NIP-F4 doesn't carry a waveform tag, so every episode renders with the same flat baseline.
// Hoist 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 })
// Bottom-rounded border on the audio player so it visually butts up against the cover's
// top-rounded corners as one card. Constant — keep out of recomposition.
private val PLAYER_BORDER_MODIFIER =
@@ -88,39 +75,14 @@ fun RenderPodcastEpisode(
Column(MaterialTheme.colorScheme.replyModifier) {
PodcastCoverCard(image, note, accountViewModel)
firstAudio?.let { audio ->
val callbackUri = remember(noteEvent) { note.toNostrUri() }
Row(
Modifier.fillMaxWidth().height(80.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GetMediaItem(
videoUri = audio.url,
title = title,
artworkUri = image,
authorName = note.author?.toBestDisplayName(),
callbackUri = callbackUri,
mimeType = audio.mediaType,
aspectRatio = null,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url),
keepPlaying = false,
waveformData = FLAT_WAVEFORM,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = FLAT_WAVEFORM,
borderModifier = PLAYER_BORDER_MODIFIER,
accountViewModel = accountViewModel,
)
}
}
}
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
title = title,
image = image,
borderModifier = PLAYER_BORDER_MODIFIER,
accountViewModel = accountViewModel,
)
}
Column(
@@ -142,12 +104,18 @@ fun RenderPodcastEpisode(
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
val descriptionTags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
TranslatableRichTextViewer(
content = it,
canPreview = canPreview,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth(),
tags = descriptionTags,
backgroundColor = backgroundColor,
id = note.idHex + "-description",
accountViewModel = accountViewModel,
nav = nav,
)
}
@@ -0,0 +1,103 @@
/*
* 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.ui.note.types
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
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
// 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.
private val PLAYER_HEIGHT_MODIFIER = Modifier.fillMaxWidth().height(80.dp)
/**
* The inline audio strip for a NIP-F4 episode (kind 54): one [AudioTag] played through the
* shared media-controller stack. [borderModifier] shapes the strip — bottom-rounded when it
* butts up under a cover image, fully rounded when it stands alone in a list.
*/
@Composable
fun PodcastEpisodeAudioPlayer(
audio: AudioTag,
note: Note,
title: String?,
image: String?,
borderModifier: Modifier,
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,
verticalAlignment = Alignment.CenterVertically,
) {
GetMediaItem(
videoUri = audio.url,
title = title,
artworkUri = image,
authorName = note.author?.toBestDisplayName(),
callbackUri = callbackUri,
mimeType = audio.mediaType,
aspectRatio = null,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url),
keepPlaying = false,
waveformData = waveform,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = borderModifier,
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -20,25 +20,36 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
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.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -49,10 +60,10 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
fun RenderPodcastMetadata(
note: Note,
makeItShort: Boolean,
@Suppress("UNUSED_PARAMETER") canPreview: Boolean,
@Suppress("UNUSED_PARAMETER") backgroundColor: MutableState<Color>,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
@Suppress("UNUSED_PARAMETER") nav: INav,
nav: INav,
) {
val noteEvent = note.event as? PodcastMetadataEvent ?: return
@@ -60,8 +71,15 @@ fun RenderPodcastMetadata(
val image = remember(noteEvent) { noteEvent.image() }
val description = remember(noteEvent) { noteEvent.description() }
val websites = remember(noteEvent) { noteEvent.websites() }
// Each podcast is its own keypair, so the author pubkey IS the podcast id used to open
// its dedicated screen with the full episode list.
val podcastPubkey = remember(noteEvent) { noteEvent.pubKey }
Column(MaterialTheme.colorScheme.replyModifier) {
Column(
MaterialTheme.colorScheme.replyModifier.clickable {
nav.nav(Route.Podcast(podcastPubkey))
},
) {
PodcastCoverCard(image, note, accountViewModel)
Column(
@@ -83,12 +101,18 @@ fun RenderPodcastMetadata(
}
description?.takeIf { !makeItShort }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
TranslatableRichTextViewer(
content = it,
canPreview = canPreview,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
}
@@ -108,6 +132,32 @@ fun RenderPodcastMetadata(
}
}
}
// Affordance that this card opens a full show page with every episode.
Row(
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = MaterialSymbols.Podcasts,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.podcast_view_episodes),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -0,0 +1,121 @@
/*
* 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.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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp))
/**
* One episode as a compact "show list" row inside a podcast's screen: publish date, title,
* short description, and an inline audio player so the episode plays without leaving the list.
* The show artwork is the same for every episode, so it's omitted here — the header carries it.
*/
@Composable
fun PodcastEpisodeListItem(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastEpisodeEvent ?: return
val title = remember(noteEvent) { noteEvent.title() }
val description = remember(noteEvent) { noteEvent.description() }
val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() }
val image = remember(noteEvent) { noteEvent.image() }
val context = LocalContext.current
val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") }
Column(
modifier =
Modifier
.fillMaxWidth()
.clickable { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
dateStr.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
firstAudio?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
title = title,
image = image,
borderModifier = PLAYER_SHAPE,
accountViewModel = accountViewModel,
)
}
}
}
@@ -0,0 +1,143 @@
/*
* 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.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
import com.vitorpamplona.amethyst.ui.theme.Size5dp
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 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
fun PodcastHeader(
metadataNote: Note,
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)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth(),
)
}
if (websites.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Size5dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
websites.forEach { website ->
Text(
text = website,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
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"
// under the cover while the relay request is still in flight.
episodeCount?.let { count ->
Text(
text = pluralStringResource(R.plurals.podcast_episode_count, count, count),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
@@ -0,0 +1,231 @@
/*
* 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.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
@Composable
fun PodcastScreen(
pubkey: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return
val metadataNote =
remember(pubkey) {
LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey))
}
val feedViewModel: OnePodcastFeedViewModel =
viewModel(
key = pubkey + "OnePodcastFeedViewModel",
factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account),
)
PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav)
}
@Composable
fun PodcastScreen(
podcast: User,
metadataNote: Note,
feedViewModel: OnePodcastFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
// Fetches both the show metadata (kind 10154) and every episode (kind 54) authored by
// this podcast's key from its outbox relays.
OnePodcastFilterAssemblerSubscription(podcast, accountViewModel)
val metadataEvent by observeNoteEvent<PodcastMetadataEvent>(metadataNote, accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarExtensibleWithBackButton(
title = {
Text(
text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
},
popBack = nav::popBack,
)
},
accountViewModel = accountViewModel,
) {
RefresheableBox(feedViewModel, true) {
SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState ->
PodcastScreenBody(
metadataNote = metadataNote,
metadataEvent = metadataEvent,
feedViewModel = feedViewModel,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun PodcastScreenBody(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
feedViewModel: OnePodcastFeedViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedState by feedViewModel.feedState.feedContent.collectAsStateWithLifecycle()
when (val state = feedState) {
is FeedState.Loaded ->
PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav)
is FeedState.Empty ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
StatusText(stringRes(R.string.podcast_no_episodes))
}
is FeedState.FeedError ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
FeedError(state.errorMessage) { feedViewModel.invalidateData() }
}
is FeedState.Loading ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
@Composable
private fun PodcastEpisodesList(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
state = listState,
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav)
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, _ -> "episode" },
) { index, episode ->
PodcastEpisodeListItem(episode, accountViewModel, nav)
if (index < items.list.lastIndex) {
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
@Composable
private fun PodcastHeaderWithStatus(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
status: @Composable () -> Unit,
) {
LazyColumn(
state = listState,
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav)
}
item("status") { status() }
}
}
@Composable
private fun StatusText(text: String) {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,62 @@
/*
* 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.ui.screen.loggedIn.podcasts.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
/**
* Every episode of a single podcast. Per NIP-F4 each podcast is its own keypair, so all
* episodes (kind 54) of a show are authored by [podcastPubkey]. Episodes are regular events,
* so they live in `LocalCache.notes`. Most-recent-first via [DefaultFeedOrder].
*/
class OnePodcastEpisodesFeedFilter(
val podcastPubkey: HexKey,
val account: Account,
val cache: LocalCache,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "podcast-" + podcastPubkey
override fun feed(): List<Note> {
val notes =
cache.notes.filterIntoSet { _, it ->
acceptableEvent(it)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { acceptableEvent(it) }
private fun acceptableEvent(note: Note): Boolean {
val noteEvent = note.event
return noteEvent is PodcastEpisodeEvent &&
noteEvent.pubKey == podcastPubkey &&
!note.isHiddenFor(account.hiddenUsers.flow.value) &&
account.isAcceptable(note)
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,45 @@
/*
* 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.ui.screen.loggedIn.podcasts.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Stable
class OnePodcastFeedViewModel(
val podcastPubkey: HexKey,
val account: Account,
) : AndroidFeedViewModel(
OnePodcastEpisodesFeedFilter(podcastPubkey, account, LocalCache),
) {
class Factory(
val podcastPubkey: HexKey,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = OnePodcastFeedViewModel(podcastPubkey, account) as T
}
}
@@ -0,0 +1,62 @@
/*
* 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.ui.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
// Per NIP-F4 a podcast is its own keypair: the show-level metadata (kind 10154) and every
// episode (kind 54) are authored by the same pubkey. So a single-podcast screen fetches
// both kinds from that one author.
private val OnePodcastKinds =
listOf(
PodcastMetadataEvent.KIND,
PodcastEpisodeEvent.KIND,
)
fun filterOnePodcast(
user: User,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// Outbox relays for the podcast key are where the show publishes. Fall back to any relay
// we've seen the key on plus stored hints when no NIP-65 list is known.
val relays =
user.outboxRelays()?.ifEmpty { null }
?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = OnePodcastKinds,
authors = listOf(user.pubkeyHex),
limit = 500,
since = since?.get(relay)?.time,
),
)
}
}
@@ -0,0 +1,38 @@
/*
* 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.ui.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class OnePodcastFeedSubAssembler(
client: INostrClient,
allKeys: () -> Set<OnePodcastQueryState>,
) : PerUserEoseManager<OnePodcastQueryState>(client, allKeys) {
override fun updateFilter(
key: OnePodcastQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterOnePodcast(user(key), since)
override fun user(key: OnePodcastQueryState) = key.user
}
@@ -0,0 +1,46 @@
/*
* 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.ui.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// Keyed by the podcast's own pubkey (each podcast is its own keypair per NIP-F4). Multiple
// screens can subscribe to the same podcast and share the single per-user subscription.
class OnePodcastQueryState(
val user: User,
)
class OnePodcastFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<OnePodcastQueryState>() {
val group =
listOf(
OnePodcastFeedSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,41 @@
/*
* 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.ui.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun OnePodcastFilterAssemblerSubscription(
podcast: User,
accountViewModel: AccountViewModel,
) {
// Different screens get their own state even when tracking the same podcast key.
val state =
remember(podcast) {
OnePodcastQueryState(podcast)
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().onePodcast)
}
+7
View File
@@ -766,6 +766,13 @@
<string name="route_music_playlists">Playlists</string>
<string name="route_podcast_episodes">Episodes</string>
<string name="route_podcasts">Podcasts</string>
<string name="podcast_view_episodes">View episodes</string>
<string name="podcast_episodes_section">Episodes</string>
<string name="podcast_no_episodes">No episodes found yet</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d episode</item>
<item quantity="other">%1$d episodes</item>
</plurals>
<string name="new_music_track">New music track</string>
<string name="new_music_playlist">New playlist</string>
<string name="music_track_title_label">Title</string>