mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
+15
-8
@@ -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)
|
||||
|
||||
+34
-17
@@ -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() }
|
||||
}
|
||||
|
||||
+27
-10
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user