feat(amethyst): surface Podcasting-2.0 episodes in the merged podcast feed

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-06-27 19:56:34 +00:00
parent 3d822dac07
commit bb31f0381b
8 changed files with 74 additions and 34 deletions
@@ -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)
}
@@ -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)
}
@@ -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 } }
@@ -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?,
@@ -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 = "") }
@@ -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<Note> {
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<Note>): Set<Note> = 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)
@@ -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<Note> {
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<Note>): Set<Note> = 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))
}
@@ -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