feat(podcasts): render Podcasting-2.0 shows in detail + thread views

Make the single-podcast detail screen spec-neutral so a Podcasting-2.0
show (kind 30078, d=podcast-metadata, e.g. "Soapbox Sessions") renders
its real cover art and title instead of the default Amethyst banner and
"Podcasts" fallback:

- PodcastHeader/PodcastScreen now take a resolved PodcastShow? instead of
  a NIP-F4-only PodcastMetadataEvent?, resolving from either the kind
  10154 (F4) or kind 30078 (P2.0) metadata event.
- FilterOnePodcast adds a #d=podcast-metadata filter on kind 30078 so the
  P2.0 show metadata is actually fetched on deep-links/fresh loads.
- NoteMaster (thread/full view) now dispatches Podcasting20EpisodeEvent,
  Podcasting20TrailerEvent, and the kind 30078 podcast-metadata variant
  to the shared podcast renderers, matching NoteCompose.

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-30 22:05:37 +00:00
parent 821e9bafdd
commit a65e37b8f5
4 changed files with 88 additions and 35 deletions
@@ -50,28 +50,35 @@ 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
import com.vitorpamplona.quartz.podcasts.PodcastShow
/**
* 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.
*
* Spec-neutral: [show] is either a NIP-F4 [PodcastMetadataEvent] (kind 10154) or a Podcasting-2.0
* show (kind 30078, `d=podcast-metadata`), both adapting to the shared [PodcastShow]. The claimed-
* author verification row is NIP-F4 only (its `p`-tag claims + kind:10064 counter-claims), so it's
* shown only when the underlying event is a [PodcastMetadataEvent].
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PodcastHeader(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
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 claimedAuthors = remember(metadataEvent) { metadataEvent?.claimedAuthors() ?: emptyList() }
val podcastPubkey = remember(metadataEvent) { metadataEvent?.pubKey }
val tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val title = remember(show) { show?.showTitle() }
val image = remember(show) { show?.showImage() }
val description = remember(show) { show?.showDescription() }
val websites = remember(show) { show?.showWebsites() ?: emptyList() }
val f4 = show as? PodcastMetadataEvent
val claimedAuthors = remember(f4) { f4?.claimedAuthors() ?: emptyList() }
val podcastPubkey = remember(f4) { f4?.pubKey }
val tags = remember(metadataNote) { metadataNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column(Modifier.fillMaxWidth()) {
PodcastCoverCard(image, metadataNote, accountViewModel)
@@ -61,8 +61,13 @@ 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.nip01Core.core.Address
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastShow
@Composable
fun PodcastScreen(
@@ -71,10 +76,18 @@ fun PodcastScreen(
nav: INav,
) {
val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return
val metadataNote =
// A show is either NIP-F4 (kind 10154) or Podcasting-2.0 (kind 30078, d=podcast-metadata).
// Resolve both addresses; whichever has an event is this podcast's metadata.
val f4Note =
remember(pubkey) {
LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey))
}
val p20Note =
remember(pubkey) {
LocalCache.getOrCreateAddressableNote(
Address(AppSpecificDataEvent.KIND, pubkey, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG),
)
}
val feedViewModel: OnePodcastFeedViewModel =
viewModel(
@@ -82,23 +95,27 @@ fun PodcastScreen(
factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account),
)
PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav)
PodcastScreen(podcast, f4Note, p20Note, feedViewModel, accountViewModel, nav)
}
@Composable
fun PodcastScreen(
podcast: User,
metadataNote: Note,
f4Note: Note,
p20Note: 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.
// Fetches the show metadata (NIP-F4 kind 10154 / Podcasting-2.0 kind 30078) and every episode
// (kind 54 / kind 30054) + trailer authored by this podcast's key from its outbox relays.
OnePodcastFilterAssemblerSubscription(podcast, accountViewModel)
val metadataEvent by observeNoteEvent<PodcastMetadataEvent>(metadataNote, accountViewModel)
val f4Event by observeNoteEvent<PodcastMetadataEvent>(f4Note, accountViewModel)
val p20Event by observeNoteEvent<AppSpecificDataEvent>(p20Note, accountViewModel)
val show: PodcastShow? = remember(f4Event, p20Event) { resolvePodcastShow(f4Event) ?: resolvePodcastShow(p20Event) }
val metadataNote = if (f4Event != null) f4Note else p20Note
DisappearingScaffold(
isInvertedLayout = false,
@@ -106,7 +123,7 @@ fun PodcastScreen(
TopBarExtensibleWithBackButton(
title = {
Text(
text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts),
text = show?.showTitle() ?: stringRes(R.string.route_podcasts),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
@@ -121,7 +138,7 @@ fun PodcastScreen(
SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState ->
PodcastScreenBody(
metadataNote = metadataNote,
metadataEvent = metadataEvent,
show = show,
feedViewModel = feedViewModel,
listState = listState,
accountViewModel = accountViewModel,
@@ -135,7 +152,7 @@ fun PodcastScreen(
@Composable
private fun PodcastScreenBody(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
feedViewModel: OnePodcastFeedViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
@@ -145,20 +162,20 @@ private fun PodcastScreenBody(
when (val state = feedState) {
is FeedState.Loaded ->
PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav)
PodcastEpisodesList(metadataNote, show, state, listState, accountViewModel, nav)
is FeedState.Empty ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
StatusText(stringRes(R.string.podcast_no_episodes))
}
is FeedState.FeedError ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
FeedError(state.errorMessage) { feedViewModel.invalidateData() }
}
is FeedState.Loading ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@@ -169,7 +186,7 @@ private fun PodcastScreenBody(
@Composable
private fun PodcastEpisodesList(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
@@ -184,7 +201,7 @@ private fun PodcastEpisodesList(
item("header") {
// The list mixes in trailers; the header count should reflect episodes only.
val episodeCount = items.list.count { it.event !is Podcasting20TrailerEvent }
PodcastHeader(metadataNote, metadataEvent, episodeCount, accountViewModel, nav)
PodcastHeader(metadataNote, show, episodeCount, accountViewModel, nav)
}
itemsIndexed(
@@ -208,7 +225,7 @@ private fun PodcastEpisodesList(
@Composable
private fun PodcastHeaderWithStatus(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
@@ -219,7 +236,7 @@ private fun PodcastHeaderWithStatus(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav)
PodcastHeader(metadataNote, show, null, accountViewModel, nav)
}
item("status") { status() }
}
@@ -25,9 +25,11 @@ 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.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
// A single-podcast screen fetches everything authored by the show's pubkey, across both drafts:
@@ -51,16 +53,31 @@ fun filterOnePodcast(
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,
),
return relays.flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = OnePodcastKinds,
authors = listOf(user.pubkeyHex),
limit = 500,
since = since?.get(relay)?.time,
),
),
// Podcasting-2.0 show metadata rides on the generic NIP-78 app-data kind (30078),
// so it needs its own #d=podcast-metadata filter rather than a bare kind match.
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AppSpecificDataEvent.KIND),
authors = listOf(user.pubkeyHex),
tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)),
limit = 1,
since = since?.get(relay)?.time,
),
),
)
}
}
@@ -220,6 +220,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
@@ -324,6 +325,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
@@ -341,6 +343,9 @@ 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 com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@@ -832,8 +837,15 @@ private fun FullBleedNoteCompose(
RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is PodcastEpisodeEvent) {
RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is Podcasting20EpisodeEvent) {
RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is Podcasting20TrailerEvent) {
PodcastTrailerListItem(baseNote, accountViewModel, nav)
} else if (noteEvent is PodcastMetadataEvent) {
RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AppSpecificDataEvent && noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) {
// kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a podcast card.
RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is CommunityPostApprovalEvent) {
RenderPostApproval(
baseNote,