feat(amethyst): render Podcasting-2.0 trailers (kind:30055)

Trailers were parsed and cached but invisible. Surface them:

- PodcastTrailerListItem: a compact trailer row with a "Trailer" badge (and
  season, when present) that plays the media through the shared episode audio
  player via PodcastAudio. Used both on the show page and as the inline
  renderer (NoteCompose dispatches kind:30055 to it).
- OnePodcastEpisodesFeedFilter now also pulls kind:30055 trailers for the
  show's pubkey; PodcastScreen renders trailer rows distinctly from episodes
  and excludes them from the header's episode count.
- FilterOnePodcast requests the show author's full output — adds kind:30054
  (a pre-existing gap) and kind:30055 alongside the NIP-F4 kinds.

Two new strings (podcast_trailer, podcast_trailer_season). Read-only; trailers
stay scoped to the show page and aren't mixed into the global episodes feed.

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 21:12:47 +00:00
parent fc2a6588c2
commit 76b05bbb85
6 changed files with 174 additions and 15 deletions
@@ -198,6 +198,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
@@ -347,6 +348,7 @@ 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.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -989,6 +991,10 @@ private fun RenderNoteRow(
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is Podcasting20TrailerEvent -> {
PodcastTrailerListItem(baseNote, accountViewModel, nav)
}
is PodcastMetadataEvent -> {
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
@@ -62,6 +62,7 @@ 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.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
@Composable
fun PodcastScreen(
@@ -181,15 +182,21 @@ private fun PodcastEpisodesList(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav)
// 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)
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, _ -> "episode" },
) { index, episode ->
PodcastEpisodeListItem(episode, accountViewModel, nav)
contentType = { _, item -> if (item.event is Podcasting20TrailerEvent) "trailer" else "episode" },
) { index, item ->
if (item.event is Podcasting20TrailerEvent) {
PodcastTrailerListItem(item, accountViewModel, nav)
} else {
PodcastEpisodeListItem(item, accountViewModel, nav)
}
if (index < items.list.lastIndex) {
HorizontalDivider(thickness = DividerThickness)
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastAudio
private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp))
/**
* A Podcasting-2.0 trailer (kind 30055) as a compact row, used on a podcast's show page and as
* the inline renderer in [com.vitorpamplona.amethyst.ui.note.NoteCompose]. A "Trailer" badge (with
* the season, when present) distinguishes it from episode rows; the media plays through the same
* player as episodes via the spec-neutral [PodcastAudio].
*/
@Composable
fun PodcastTrailerListItem(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? Podcasting20TrailerEvent ?: return
val title = remember(noteEvent) { noteEvent.title() }
val season = remember(noteEvent) { noteEvent.season() }
val media =
remember(noteEvent) {
noteEvent.url()?.let { PodcastAudio(it, noteEvent.mimeType()) }
}
val context = LocalContext.current
val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringRes(R.string.podcast_trailer),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
season?.let {
Text(
text = stringRes(R.string.podcast_trailer_season, it),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
dateStr.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
media?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
title = title,
image = null,
borderModifier = PLAYER_SHAPE,
accountViewModel = accountViewModel,
)
}
}
}
@@ -28,14 +28,16 @@ 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.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
/**
* 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].
* Everything a single podcast (authored by [podcastPubkey]) publishes for its show page: NIP-F4
* episodes (kind 54, regular events in `LocalCache.notes`), Podcasting-2.0 episodes (kind 30054)
* and Podcasting-2.0 trailers (kind 30055, both addressable events in `LocalCache.addressables`).
* Episodes go through the shared [PodcastEpisode] interface; trailers are matched by their concrete
* type and rendered distinctly. In every model the show's pubkey authors its own content.
* Most-recent-first via [sortedByDefaultFeedOrder].
*/
class OnePodcastEpisodesFeedFilter(
val podcastPubkey: HexKey,
@@ -49,18 +51,23 @@ class OnePodcastEpisodesFeedFilter(
cache.notes.filterIntoSet { _, it ->
acceptableEvent(it)
}
val addressable =
val episodes =
cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it ->
acceptableEvent(it)
}
return sort(regular + addressable)
val trailers =
cache.addressables.filterIntoSet(Podcasting20TrailerEvent.KIND) { _, it ->
acceptableEvent(it)
}
return sort(regular + episodes + trailers)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { acceptableEvent(it) }
private fun acceptableEvent(note: Note): Boolean {
val noteEvent = note.event ?: return false
return noteEvent is PodcastEpisode &&
val isShowContent = noteEvent is PodcastEpisode || noteEvent is Podcasting20TrailerEvent
return isShowContent &&
noteEvent.pubKey == podcastPubkey &&
!note.isHiddenFor(account.hiddenUsers.flow.value) &&
account.isAcceptable(note)
@@ -27,14 +27,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
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.trailer.Podcasting20TrailerEvent
// Per NIP-F4 a podcast is its own keypair: the show-level metadata (kind 10154) and every
// episode (kind 54) are authored by the same pubkey. So a single-podcast screen fetches
// both kinds from that one author.
// A single-podcast screen fetches everything authored by the show's pubkey, across both drafts:
// NIP-F4 metadata (kind 10154) + episodes (kind 54), and Podcasting-2.0 episodes (kind 30054) +
// trailers (kind 30055). In both models the show key authors its own episodes and trailers.
private val OnePodcastKinds =
listOf(
PodcastMetadataEvent.KIND,
PodcastEpisodeEvent.KIND,
Podcasting20EpisodeEvent.KIND,
Podcasting20TrailerEvent.KIND,
)
fun filterOnePodcast(
+2
View File
@@ -913,6 +913,8 @@
<string name="route_podcasts">Podcasts</string>
<string name="podcast_view_episodes">View episodes</string>
<string name="podcast_no_episodes">No episodes found yet</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_trailer_season">Season %1$d</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d episode</item>
<item quantity="other">%1$d episodes</item>