From bb31f0381bc88b311828a2b77739b467e1282aef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:56:34 +0000 Subject: [PATCH] feat(amethyst): surface Podcasting-2.0 episodes in the merged podcast feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire kind:30054 (and kind:30055 trailers) end-to-end so Podcasting-2.0 episodes appear alongside NIP-F4 kind:54 episodes in one list: - LocalCache consumes both new kinds as addressable replaceables. - PodcastEpisodesFeedFilter and OnePodcastEpisodesFeedFilter now merge kind:54 (LocalCache.notes) with kind:30054 (LocalCache.addressables), gating on the shared PodcastEpisode interface. - The episode renderer, compact list row, and inline audio player read through PodcastEpisode / PodcastAudio, so one render path serves both kinds; NoteCompose dispatches kind:30054 to it. - Relay subscriptions request kind:30054 alongside kind:54. Read support only — Amethyst still publishes NIP-F4. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/model/LocalCache.kt | 10 ++++++++ .../amethyst/ui/note/NoteCompose.kt | 5 ++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 16 ++++++++----- .../note/types/PodcastEpisodeAudioPlayer.kt | 11 +++++---- .../podcasts/PodcastEpisodeListItem.kt | 14 ++++++----- .../dal/OnePodcastEpisodesFeedFilter.kt | 24 ++++++++++++------- .../podcasts/dal/PodcastEpisodesFeedFilter.kt | 22 ++++++++++------- .../FilterPodcastEventsByAuthors.kt | 6 ++++- 8 files changed, 74 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index fc6a9d14e3..b5a1d5596f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -289,6 +289,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -3763,6 +3765,14 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is Podcasting20EpisodeEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is Podcasting20TrailerEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is LnZapEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a9c17d31f9..feeaedab44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -344,6 +344,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -982,6 +983,10 @@ private fun RenderNoteRow( RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is Podcasting20EpisodeEvent -> { + RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is PodcastMetadataEvent -> { RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index faa0258569..330ba7348d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode // 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. @@ -60,14 +60,18 @@ fun RenderPodcastEpisode( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PodcastEpisodeEvent ?: return + val noteEvent = note.event ?: return + // Both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054) episodes implement PodcastEpisode, + // so this one renderer serves both. Title/image/description/audio come through the shared + // abstraction; content and tags come from the underlying event. + val episode = noteEvent as? PodcastEpisode ?: return - val title = remember(noteEvent) { noteEvent.title() } - val image = remember(noteEvent) { noteEvent.image() } - val description = remember(noteEvent) { noteEvent.description() } + val title = remember(noteEvent) { episode.episodeTitle() } + val image = remember(noteEvent) { episode.episodeImage() } + val description = remember(noteEvent) { episode.episodeDescription() } // Pick the first audio URL. Publishers may emit multiple containers in their preferred // order; clients with codec preferences can extend this later. - val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } + val firstAudio = remember(noteEvent) { episode.episodeAudio().firstOrNull() } // Suppress the markdown block if blank — title + description already describe a short // episode. Otherwise hand off to RichText below. val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } } 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 51cde34882..bcbc5f95fe 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 @@ -35,7 +35,7 @@ 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 +import com.vitorpamplona.quartz.podcasts.PodcastAudio // 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 @@ -43,13 +43,14 @@ import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag 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. + * The inline audio strip for a podcast episode: one [PodcastAudio] played through the shared + * media-controller stack. Works for both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054) + * episodes via the spec-neutral audio holder. [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, + audio: PodcastAudio, note: Note, title: String?, image: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index e75275cb42..e96ce2d12c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -44,7 +44,7 @@ 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 +import com.vitorpamplona.quartz.podcasts.PodcastEpisode private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp)) @@ -59,12 +59,14 @@ fun PodcastEpisodeListItem( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PodcastEpisodeEvent ?: return + val noteEvent = note.event ?: return + // Both kind 54 (NIP-F4) and kind 30054 (Podcasting 2.0) episodes implement PodcastEpisode. + val episode = noteEvent as? PodcastEpisode ?: 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 title = remember(noteEvent) { episode.episodeTitle() } + val description = remember(noteEvent) { episode.episodeDescription() } + val firstAudio = remember(noteEvent) { episode.episodeAudio().firstOrNull() } + val image = remember(noteEvent) { episode.episodeImage() } val context = LocalContext.current val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index 05522350b6..ab95e43d20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -23,15 +23,19 @@ 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.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode /** - * 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 [sortedByDefaultFeedOrder]. + * Every episode of a single podcast, authored by [podcastPubkey]. NIP-F4 episodes (kind 54, + * regular events in `LocalCache.notes`) and Podcasting-2.0 episodes (kind 30054, addressable + * events in `LocalCache.addressables`) both implement [PodcastEpisode] and are merged here — + * in both models the show's pubkey is the episode author. Most-recent-first via + * [sortedByDefaultFeedOrder]. */ class OnePodcastEpisodesFeedFilter( val podcastPubkey: HexKey, @@ -41,18 +45,22 @@ class OnePodcastEpisodesFeedFilter( override fun feedKey(): String = "podcast-" + podcastPubkey override fun feed(): List { - val notes = + val regular = cache.notes.filterIntoSet { _, it -> acceptableEvent(it) } - return sort(notes) + val addressable = + cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it -> + acceptableEvent(it) + } + return sort(regular + addressable) } override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { acceptableEvent(it) } private fun acceptableEvent(note: Note): Boolean { - val noteEvent = note.event - return noteEvent is PodcastEpisodeEvent && + val noteEvent = note.event ?: return false + return noteEvent is PodcastEpisode && noteEvent.pubKey == podcastPubkey && !note.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt index 058e07bdac..6bb8070d66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode /** - * Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike - * music tracks/playlists which are addressable. Same follow-list + hidden/blocked - * gate as MusicTracksFeedFilter. + * Merges the two podcast-episode kinds into one list. NIP-F4 episodes (kind 54) are regular + * events in `LocalCache.notes`; Podcasting-2.0 episodes (kind 30054) are addressable events in + * `LocalCache.addressables`. Both implement [PodcastEpisode], so a single accept gate covers + * them. Same follow-list + hidden/blocked gate as MusicTracksFeedFilter. */ class PodcastEpisodesFeedFilter( val account: Account, @@ -54,11 +56,15 @@ class PodcastEpisodesFeedFilter( override fun feed(): List { val params = buildFilterParams(account) - val notes = + val regular = LocalCache.notes.filterIntoSet { _, it -> accept(it, params) } - return sort(notes) + val addressable = + LocalCache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it -> + accept(it, params) + } + return sort(regular + addressable) } override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) @@ -78,8 +84,8 @@ class PodcastEpisodesFeedFilter( note: Note, params: FilterByListParams, ): Boolean { - val noteEvent = note.event - return noteEvent is PodcastEpisodeEvent && + val noteEvent = note.event ?: return false + return noteEvent is PodcastEpisode && params.match(noteEvent, note.relays) && (params.isHiddenList || account.isAcceptable(note)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 4a56858280..2bb75eedf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -29,10 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent // Each podcast screen issues its own REQ keyed on its own follow-list selector — keep // the kind lists split so episodes and show metadata never co-mingle in one filter. -internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND) +// Episodes span both podcast drafts: NIP-F4 kind 54 (podcast-is-a-keypair) and Podcasting-2.0 +// kind 30054 (creator-is-a-keypair). Both are authored by the followed pubkey, so the same +// author-scoped REQ pulls them into one merged feed. +internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND) internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) // NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is