mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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 } }
|
||||
|
||||
+6
-5
@@ -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?,
|
||||
|
||||
+8
-6
@@ -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 = "") }
|
||||
|
||||
+16
-8
@@ -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)
|
||||
|
||||
+14
-8
@@ -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))
|
||||
}
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user