diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index cee36a11ad..d7cd351841 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } 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 b161ad3aab..b599f33aef 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 @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt new file mode 100644 index 0000000000..1f24265f4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index ab95e43d20..fd9a704db8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -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): Set = 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) 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 e526cf3174..4fbed64bfc 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 @@ -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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 013e8c069e..02dd939ac5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -913,6 +913,8 @@ Podcasts View episodes No episodes found yet + Trailer + Season %1$d %1$d episode %1$d episodes