diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 946eb6bf7d..31970e4661 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index b599f33aef..493747c106 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -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(metadataNote, accountViewModel) + val f4Event by observeNoteEvent(f4Note, accountViewModel) + val p20Event by observeNoteEvent(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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt index 4fbed64bfc..6c061b11a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -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, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index e2319e7d03..85c84cfa69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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,