mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+11
-4
@@ -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)
|
||||
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -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)
|
||||
|
||||
+7
-3
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user