From 46e618aabe416952aa7bb5664b69e6d6aff50492 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 19:24:50 +0000 Subject: [PATCH 1/4] feat(podcasts): tap a podcast to open a show screen with all episodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each item in the Podcasts feed (kind 10154 show metadata) now opens a dedicated podcast screen built around the show's pubkey — per NIP-F4 a podcast is its own keypair, so its metadata and every episode (kind 54) share one author. - New Route.Podcast(pubkey) + AppNavigation binding. - PodcastScreen: modern hero header (cover art, title, websites, tappable expand/collapse description, "N episodes" section) followed by every episode in a LazyColumn. - PodcastEpisodeListItem: compact "show list" row (date, title, summary) with an inline audio player so episodes play without leaving the list. - OnePodcast datasource (per-user EOSE manager) fetches the show metadata and episodes from the podcast key's outbox relays. - OnePodcastEpisodesFeedFilter + OnePodcastFeedViewModel scope LocalCache to a single podcast's episodes. - Podcasts feed metadata card is now clickable with a "View episodes" affordance. --- .../RelaySubscriptionsCoordinator.kt | 3 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/note/types/PodcastMetadata.kt | 46 +++- .../podcasts/PodcastEpisodeListItem.kt | 158 ++++++++++++ .../screen/loggedIn/podcasts/PodcastHeader.kt | 137 +++++++++++ .../screen/loggedIn/podcasts/PodcastScreen.kt | 230 ++++++++++++++++++ .../dal/OnePodcastEpisodesFeedFilter.kt | 62 +++++ .../podcasts/dal/OnePodcastFeedViewModel.kt | 45 ++++ .../podcasts/datasource/FilterOnePodcast.kt | 62 +++++ .../datasource/OnePodcastFeedSubAssembler.kt | 38 +++ .../datasource/OnePodcastFilterAssembler.kt | 46 ++++ .../OnePodcastFilterAssemblerSubscription.kt | 41 ++++ amethyst/src/main/res/values/strings.xml | 7 + 14 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFeedSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 035539b7b1..9357f8fee5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFi import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler @@ -133,6 +134,7 @@ class RelaySubscriptionsCoordinator( val musicPlaylists = MusicPlaylistsFilterAssembler(client) val podcastEpisodes = PodcastEpisodesFilterAssembler(client) val podcasts = PodcastsFilterAssembler(client) + val onePodcast = OnePodcastFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) @@ -179,6 +181,7 @@ class RelaySubscriptionsCoordinator( musicPlaylists, podcastEpisodes, podcasts, + onePodcast, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e06be196ac..e382189a5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -148,6 +148,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessage import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen @@ -297,6 +298,7 @@ fun BuildNavigation( composableFromEnd { MusicPlaylistsScreen(accountViewModel, nav) } composableFromEnd { PodcastEpisodesScreen(accountViewModel, nav) } composableFromEnd { PodcastsScreen(accountViewModel, nav) } + composableFromEndArgs { PodcastScreen(it.pubkey, accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b319573c8e..7662d74b39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -162,6 +162,10 @@ sealed class Route { @Serializable object Podcasts : Route() + @Serializable data class Podcast( + val pubkey: String, + ) : Route() + @Serializable data class NewMusicTrack( val dTag: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index 03e880e5dc..b9915f342e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -20,25 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route 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.amethyst.ui.theme.replyModifier @@ -52,7 +61,7 @@ fun RenderPodcastMetadata( @Suppress("UNUSED_PARAMETER") canPreview: Boolean, @Suppress("UNUSED_PARAMETER") backgroundColor: MutableState, accountViewModel: AccountViewModel, - @Suppress("UNUSED_PARAMETER") nav: INav, + nav: INav, ) { val noteEvent = note.event as? PodcastMetadataEvent ?: return @@ -60,8 +69,15 @@ fun RenderPodcastMetadata( val image = remember(noteEvent) { noteEvent.image() } val description = remember(noteEvent) { noteEvent.description() } val websites = remember(noteEvent) { noteEvent.websites() } + // Each podcast is its own keypair, so the author pubkey IS the podcast id used to open + // its dedicated screen with the full episode list. + val podcastPubkey = remember(noteEvent) { noteEvent.pubKey } - Column(MaterialTheme.colorScheme.replyModifier) { + Column( + MaterialTheme.colorScheme.replyModifier.clickable { + nav.nav(Route.Podcast(podcastPubkey)) + }, + ) { PodcastCoverCard(image, note, accountViewModel) Column( @@ -108,6 +124,32 @@ fun RenderPodcastMetadata( } } } + + // Affordance that this card opens a full show page with every episode. + Row( + modifier = Modifier.fillMaxWidth().padding(top = Size5dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Podcasts, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.podcast_view_episodes), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt new file mode 100644 index 0000000000..0c91319e82 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -0,0 +1,158 @@ +/* + * 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.clickable +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.height +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.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.types.RenderVoicePlayer +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 + +// NIP-F4 episodes carry no waveform tag, so render a flat baseline. Shared at file scope so +// every visible row points at the same List instead of allocating a fresh one per card. +private const val WAVEFORM_SAMPLES = 96 +private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) + +private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp)) + +/** + * One episode as a compact "show list" row inside a podcast's screen: publish date, title, + * short description, and an inline audio player so the episode plays without leaving the list. + * The show artwork is the same for every episode, so it's omitted here — the header carries it. + */ +@Composable +fun PodcastEpisodeListItem( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PodcastEpisodeEvent ?: 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 context = LocalContext.current + val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + dateStr.takeIf { it.isNotBlank() }?.let { + Text( + text = it.uppercase(), + 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(), + ) + } + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + firstAudio?.let { audio -> + val callbackUri = remember(noteEvent) { note.toNostrUri() } + + Row( + Modifier.fillMaxWidth().height(72.dp).padding(top = Size5dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = audio.url, + title = title, + artworkUri = image, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + mimeType = audio.mediaType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), + keepPlaying = false, + waveformData = FLAT_WAVEFORM, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = FLAT_WAVEFORM, + borderModifier = PLAYER_SHAPE, + accountViewModel = accountViewModel, + ) + } + } + } + } + } +} 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 new file mode 100644 index 0000000000..dfe615a59a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -0,0 +1,137 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +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.note.types.PodcastCoverCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +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 + +/** + * Hero header for a single podcast screen: large cover art, title, websites and a tappable + * (collapse/expand) description, followed by the "Episodes (N)" section divider that the + * episode rows hang under. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PodcastHeader( + metadataNote: Note, + metadataEvent: PodcastMetadataEvent?, + episodeCount: Int, + accountViewModel: AccountViewModel, +) { + 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() } + + Column(Modifier.fillMaxWidth()) { + PodcastCoverCard(image, metadataNote, accountViewModel) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (websites.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + websites.forEach { website -> + Text( + text = website, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + description?.let { ExpandableDescription(it) } + } + + Text( + text = pluralStringResource(R.plurals.podcast_episode_count, episodeCount, episodeCount), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + + HorizontalDivider(thickness = DividerThickness) + } +} + +@Composable +private fun ExpandableDescription(description: String) { + var expanded by remember(description) { mutableStateOf(false) } + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (expanded) Int.MAX_VALUE else 4, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + ) +} 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 new file mode 100644 index 0000000000..10ce583ed5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -0,0 +1,230 @@ +/* + * 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.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssemblerSubscription +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.nipF4Podcasts.metadata.PodcastMetadataEvent + +@Composable +fun PodcastScreen( + pubkey: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return + val metadataNote = + remember(pubkey) { + LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey)) + } + + val feedViewModel: OnePodcastFeedViewModel = + viewModel( + key = pubkey + "OnePodcastFeedViewModel", + factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account), + ) + + PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav) +} + +@Composable +fun PodcastScreen( + podcast: User, + metadataNote: 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. + OnePodcastFilterAssemblerSubscription(podcast, accountViewModel) + + val metadataEvent by observeNoteEvent(metadataNote, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarExtensibleWithBackButton( + title = { + Text( + text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + }, + popBack = nav::popBack, + ) + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(feedViewModel, true) { + SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState -> + PodcastScreenBody( + metadataNote = metadataNote, + metadataEvent = metadataEvent, + feedViewModel = feedViewModel, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +private fun PodcastScreenBody( + metadataNote: Note, + metadataEvent: PodcastMetadataEvent?, + feedViewModel: OnePodcastFeedViewModel, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by feedViewModel.feedState.feedContent.collectAsStateWithLifecycle() + + when (val state = feedState) { + is FeedState.Loaded -> + PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav) + + is FeedState.Empty -> + PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) { + StatusText(stringRes(R.string.podcast_no_episodes)) + } + + is FeedState.FeedError -> + PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) { + FeedError(state.errorMessage) { feedViewModel.invalidateData() } + } + + is FeedState.Loading -> + PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } +} + +@Composable +private fun PodcastEpisodesList( + metadataNote: Note, + metadataEvent: PodcastMetadataEvent?, + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + state = listState, + contentPadding = rememberFeedContentPadding(FeedPadding), + ) { + item("header") { + PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel) + } + + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, _ -> "episode" }, + ) { index, episode -> + PodcastEpisodeListItem(episode, accountViewModel, nav) + + if (index < items.list.lastIndex) { + HorizontalDivider(thickness = DividerThickness) + } + } + } +} + +@Composable +private fun PodcastHeaderWithStatus( + metadataNote: Note, + metadataEvent: PodcastMetadataEvent?, + listState: LazyListState, + accountViewModel: AccountViewModel, + status: @Composable () -> Unit, +) { + LazyColumn( + state = listState, + contentPadding = rememberFeedContentPadding(FeedPadding), + ) { + item("header") { + PodcastHeader(metadataNote, metadataEvent, 0, accountViewModel) + } + item("status") { status() } + } +} + +@Composable +private fun StatusText(text: String) { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + textAlign = TextAlign.Center, + ) + } +} 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 new file mode 100644 index 0000000000..6a403d2e37 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -0,0 +1,62 @@ +/* + * 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.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent + +/** + * 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 [DefaultFeedOrder]. + */ +class OnePodcastEpisodesFeedFilter( + val podcastPubkey: HexKey, + val account: Account, + val cache: LocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "podcast-" + podcastPubkey + + override fun feed(): List { + val notes = + cache.notes.filterIntoSet { _, it -> + acceptableEvent(it) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { acceptableEvent(it) } + + private fun acceptableEvent(note: Note): Boolean { + val noteEvent = note.event + return noteEvent is PodcastEpisodeEvent && + noteEvent.pubKey == podcastPubkey && + !note.isHiddenFor(account.hiddenUsers.flow.value) && + account.isAcceptable(note) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt new file mode 100644 index 0000000000..f1ce9ebbac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastFeedViewModel.kt @@ -0,0 +1,45 @@ +/* + * 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.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Stable +class OnePodcastFeedViewModel( + val podcastPubkey: HexKey, + val account: Account, +) : AndroidFeedViewModel( + OnePodcastEpisodesFeedFilter(podcastPubkey, account, LocalCache), + ) { + class Factory( + val podcastPubkey: HexKey, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = OnePodcastFeedViewModel(podcastPubkey, account) as T + } +} 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 new file mode 100644 index 0000000000..e526cf3174 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -0,0 +1,62 @@ +/* + * 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.datasource + +import com.vitorpamplona.amethyst.model.LocalCache +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.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +// 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. +private val OnePodcastKinds = + listOf( + PodcastMetadataEvent.KIND, + PodcastEpisodeEvent.KIND, + ) + +fun filterOnePodcast( + user: User, + since: SincePerRelayMap?, +): List { + // Outbox relays for the podcast key are where the show publishes. Fall back to any relay + // we've seen the key on plus stored hints when no NIP-65 list is known. + val relays = + 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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFeedSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFeedSubAssembler.kt new file mode 100644 index 0000000000..d9a0fbd588 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFeedSubAssembler.kt @@ -0,0 +1,38 @@ +/* + * 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.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class OnePodcastFeedSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: OnePodcastQueryState, + since: SincePerRelayMap?, + ): List = filterOnePodcast(user(key), since) + + override fun user(key: OnePodcastQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssembler.kt new file mode 100644 index 0000000000..5725689004 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssembler.kt @@ -0,0 +1,46 @@ +/* + * 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.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +// Keyed by the podcast's own pubkey (each podcast is its own keypair per NIP-F4). Multiple +// screens can subscribe to the same podcast and share the single per-user subscription. +class OnePodcastQueryState( + val user: User, +) + +class OnePodcastFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + OnePodcastFeedSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..4d67a0db95 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/OnePodcastFilterAssemblerSubscription.kt @@ -0,0 +1,41 @@ +/* + * 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.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun OnePodcastFilterAssemblerSubscription( + podcast: User, + accountViewModel: AccountViewModel, +) { + // Different screens get their own state even when tracking the same podcast key. + val state = + remember(podcast) { + OnePodcastQueryState(podcast) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().onePodcast) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2df3e835a9..bfc2d40537 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -766,6 +766,13 @@ Playlists Episodes Podcasts + View episodes + Episodes + No episodes found yet + + %1$d episode + %1$d episodes + New music track New playlist Title From c504b465e146ed82f9e94c3a5f667ad4c10cb79f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 20:08:11 +0000 Subject: [PATCH 2/4] refactor(podcasts): share the episode audio player; fix list player height Audit follow-ups on the podcast screen: - Extract the duplicated GetMediaItem -> GetVideoController -> RenderVoicePlayer block (and the FLAT_WAVEFORM constants) from both PodcastEpisode and PodcastEpisodeListItem into one PodcastEpisodeAudioPlayer composable. The border shape stays a parameter (bottom-rounded under the cover vs. fully rounded in the list). - Fix the list row clipping the 75dp play button: the row hard-coded 72dp, below the voice player's 80dp minimum. Height now lives in the shared player so both call sites stay identical. - Header no longer flashes "0 episodes" while the relay request is in flight: episodeCount is nullable and the count/divider only render once loaded. - Drop the odd uppercasing of the episode date (looked wrong on relative values like "3d"). --- .../amethyst/ui/note/types/PodcastEpisode.kt | 54 ++--------- .../note/types/PodcastEpisodeAudioPlayer.kt | 97 +++++++++++++++++++ .../podcasts/PodcastEpisodeListItem.kt | 57 ++--------- .../screen/loggedIn/podcasts/PodcastHeader.kt | 26 ++--- .../screen/loggedIn/podcasts/PodcastScreen.kt | 2 +- 5 files changed, 131 insertions(+), 105 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 65c230a3bd..0187688979 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -33,7 +31,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState 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.graphics.Color @@ -42,10 +39,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController -import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground -import com.vitorpamplona.amethyst.service.playback.composable.WaveformData -import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -53,12 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent -// NIP-F4 doesn't carry a waveform tag, so every episode renders with the same flat baseline. -// Hoist to a top-level constant so we share one List+WaveformData across the whole -// feed instead of allocating a fresh 96-element list per visible card. -private const val WAVEFORM_SAMPLES = 96 -private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) - // 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. private val PLAYER_BORDER_MODIFIER = @@ -88,39 +75,14 @@ fun RenderPodcastEpisode( Column(MaterialTheme.colorScheme.replyModifier) { PodcastCoverCard(image, note, accountViewModel) firstAudio?.let { audio -> - val callbackUri = remember(noteEvent) { note.toNostrUri() } - - Row( - Modifier.fillMaxWidth().height(80.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - GetMediaItem( - videoUri = audio.url, - title = title, - artworkUri = image, - authorName = note.author?.toBestDisplayName(), - callbackUri = callbackUri, - mimeType = audio.mediaType, - aspectRatio = null, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), - keepPlaying = false, - waveformData = FLAT_WAVEFORM, - ) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - muted = false, - ) { controller -> - PauseControllerWhenInBackground(controller) - RenderVoicePlayer( - mediaItem = mediaItem, - controllerState = controller, - waveform = FLAT_WAVEFORM, - borderModifier = PLAYER_BORDER_MODIFIER, - accountViewModel = accountViewModel, - ) - } - } - } + PodcastEpisodeAudioPlayer( + audio = audio, + note = note, + title = title, + image = image, + borderModifier = PLAYER_BORDER_MODIFIER, + accountViewModel = accountViewModel, + ) } Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt new file mode 100644 index 0000000000..3f8361581c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -0,0 +1,97 @@ +/* + * 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.note.types + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag + +// NIP-F4 episodes carry no waveform tag, so every episode renders with the same flat baseline. +// Hoisted to a top-level constant so we share one List + WaveformData across the whole +// feed instead of allocating a fresh 96-element list per visible card. +private const val WAVEFORM_SAMPLES = 96 +private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) + +// 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 +// per-podcast list row stay pixel-identical. +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. + */ +@Composable +fun PodcastEpisodeAudioPlayer( + audio: AudioTag, + note: Note, + title: String?, + image: String?, + borderModifier: Modifier, + accountViewModel: AccountViewModel, +) { + val callbackUri = remember(note) { note.toNostrUri() } + + Row( + PLAYER_HEIGHT_MODIFIER, + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = audio.url, + title = title, + artworkUri = image, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + mimeType = audio.mediaType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), + keepPlaying = false, + waveformData = FLAT_WAVEFORM, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = FLAT_WAVEFORM, + borderModifier = borderModifier, + accountViewModel = accountViewModel, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index 0c91319e82..e75275cb42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -23,16 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts import androidx.compose.foundation.clickable 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.height 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 @@ -40,24 +37,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController -import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground -import com.vitorpamplona.amethyst.service.playback.composable.WaveformData -import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.timeAgo -import com.vitorpamplona.amethyst.ui.note.types.RenderVoicePlayer +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 -// NIP-F4 episodes carry no waveform tag, so render a flat baseline. Shared at file scope so -// every visible row points at the same List instead of allocating a fresh one per card. -private const val WAVEFORM_SAMPLES = 96 -private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) - private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp)) /** @@ -91,7 +79,7 @@ fun PodcastEpisodeListItem( ) { dateStr.takeIf { it.isNotBlank() }?.let { Text( - text = it.uppercase(), + text = it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.grayText, ) @@ -120,39 +108,14 @@ fun PodcastEpisodeListItem( } firstAudio?.let { audio -> - val callbackUri = remember(noteEvent) { note.toNostrUri() } - - Row( - Modifier.fillMaxWidth().height(72.dp).padding(top = Size5dp), - verticalAlignment = Alignment.CenterVertically, - ) { - GetMediaItem( - videoUri = audio.url, - title = title, - artworkUri = image, - authorName = note.author?.toBestDisplayName(), - callbackUri = callbackUri, - mimeType = audio.mediaType, - aspectRatio = null, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), - keepPlaying = false, - waveformData = FLAT_WAVEFORM, - ) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - muted = false, - ) { controller -> - PauseControllerWhenInBackground(controller) - RenderVoicePlayer( - mediaItem = mediaItem, - controllerState = controller, - waveform = FLAT_WAVEFORM, - borderModifier = PLAYER_SHAPE, - accountViewModel = accountViewModel, - ) - } - } - } + PodcastEpisodeAudioPlayer( + audio = audio, + note = note, + title = title, + image = image, + borderModifier = PLAYER_SHAPE, + accountViewModel = accountViewModel, + ) } } } 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 dfe615a59a..7f7f3efd10 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 @@ -59,7 +59,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent fun PodcastHeader( metadataNote: Note, metadataEvent: PodcastMetadataEvent?, - episodeCount: Int, + episodeCount: Int?, accountViewModel: AccountViewModel, ) { val title = remember(metadataEvent) { metadataEvent?.title() } @@ -106,17 +106,21 @@ fun PodcastHeader( description?.let { ExpandableDescription(it) } } - Text( - text = pluralStringResource(R.plurals.podcast_episode_count, episodeCount, episodeCount), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - ) + // Only render once episodes have actually loaded — avoids flashing "0 episodes" + // under the cover while the relay request is still in flight. + episodeCount?.let { count -> + Text( + text = pluralStringResource(R.plurals.podcast_episode_count, count, count), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + ) - HorizontalDivider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) + } } } 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 10ce583ed5..d6bea463dc 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 @@ -211,7 +211,7 @@ private fun PodcastHeaderWithStatus( contentPadding = rememberFeedContentPadding(FeedPadding), ) { item("header") { - PodcastHeader(metadataNote, metadataEvent, 0, accountViewModel) + PodcastHeader(metadataNote, metadataEvent, null, accountViewModel) } item("status") { status() } } From 063ebbebce662741983f3bed8663ac937fdf2d7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 21:21:54 +0000 Subject: [PATCH 3/4] feat(podcasts): translatable show description + synthetic playback waveform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The show description on the podcast screen now renders through TranslatableRichTextViewer (rich text + translation), matching how a profile's About is shown, instead of a plain Text. PodcastHeader takes a nav for the rich content's links. - Audio playback now shows a decorative per-episode waveform when the source has none, instead of a flat strip — same behaviour as music tracks. Extracted MusicTrack's private syntheticWaveformFor into a shared wavefront/SyntheticWaveform.kt used by both music and podcast players; the podcast player still prefers a real IMeta waveform when one is present. --- .../composable/wavefront/SyntheticWaveform.kt | 66 +++++++++++++++++++ .../amethyst/ui/note/types/MusicTrack.kt | 42 +----------- .../note/types/PodcastEpisodeAudioPlayer.kt | 22 ++++--- .../screen/loggedIn/podcasts/PodcastHeader.kt | 48 +++++++------- .../screen/loggedIn/podcasts/PodcastScreen.kt | 11 ++-- 5 files changed, 112 insertions(+), 77 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt new file mode 100644 index 0000000000..a9f2a26726 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/SyntheticWaveform.kt @@ -0,0 +1,66 @@ +/* + * 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.service.playback.composable.wavefront + +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import kotlin.math.sin +import kotlin.random.Random + +private const val SYNTHETIC_WAVEFORM_SAMPLES = 96 +private const val TWO_PI = (Math.PI * 2).toFloat() + +/** + * Builds a stable, decorative waveform from a [seed] (usually an event id). ExoPlayer drives + * actual playback progress — these bars only exist so an audio player has the familiar + * "audio silhouette" shape rather than a flat strip when the source carries no `waveform` tag. + * Shared by every audio renderer whose spec omits waveforms (music tracks, podcast episodes, …). + * + * Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter + * envelope) is drawn from the seeded RNG before the per-bar loop, so two different seeds + * produce visibly different waveforms — earlier versions used a fixed envelope + carrier that + * only varied by per-bar noise, which made every track look like the same slow-fade sine with + * minor wiggle. + */ +fun syntheticWaveformFor(seed: String): WaveformData { + // Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide + // (rare for hex addresses but cheap to defend against) still differ via the bit shuffle. + val rng = Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong()) + + // Shape parameters drawn once per seed — these are what makes seed A look different + // from seed B at a glance. + val phaseOffset = rng.nextFloat() * TWO_PI + val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.5–8 full cycles across the strip + val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.18–0.36 + val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.28–0.50 + val envelopeStrength = rng.nextFloat() * 0.45f // 0.0–0.45; some fade in/out, others don't + val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.18–0.40 + + val bars = + List(SYNTHETIC_WAVEFORM_SAMPLES) { index -> + val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES + // Optional gentle fade so some taper at the ends, others stay even. + val envelope = 1f - envelopeStrength * (1f - sin(phase * Math.PI).toFloat()) + val carrier = sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight + val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength + ((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f) + } + return WaveformData(bars) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 3b20bba162..019f30c700 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -62,8 +62,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground import com.vitorpamplona.amethyst.service.playback.composable.VideoView -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.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -438,46 +438,6 @@ private fun TopicChip( ) } -private const val SYNTHETIC_WAVEFORM_SAMPLES = 96 -private const val TWO_PI = (Math.PI * 2).toFloat() - -/** - * Builds a stable, decorative waveform from the track id. ExoPlayer drives actual playback - * progress — these bars only exist so the audio player has the familiar "music silhouette" - * shape rather than a flat strip when the spec has no `waveform` tag. - * - * Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter - * envelope) is drawn from the seeded RNG before the per-bar loop, so two different track ids - * produce visibly different waveforms — earlier versions used a fixed envelope + carrier that - * only varied by per-bar noise, which made every track look like the same slow-fade sine with - * minor wiggle. - */ -private fun syntheticWaveformFor(seed: String): WaveformData { - // Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide - // (rare for hex addresses but cheap to defend against) still differ via the bit shuffle. - val rng = kotlin.random.Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong()) - - // Shape parameters drawn once per track — these are what makes track A look different - // from track B at a glance. - val phaseOffset = rng.nextFloat() * TWO_PI - val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.5–8 full cycles across the strip - val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.18–0.36 - val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.28–0.50 - val envelopeStrength = rng.nextFloat() * 0.45f // 0.0–0.45; some tracks fade in/out, others don't - val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.18–0.40 - - val bars = - List(SYNTHETIC_WAVEFORM_SAMPLES) { index -> - val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES - // Optional gentle fade so some tracks taper at the ends, others stay even. - val envelope = 1f - envelopeStrength * (1f - kotlin.math.sin(phase * Math.PI).toFloat()) - val carrier = kotlin.math.sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight - val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength - ((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f) - } - return WaveformData(bars) -} - // --------------------------------------------------------------------------- // @Preview composables. These wire a constructed MusicTrackEvent through // LocalCache so the renderer pulls the real Note path it would in production. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 3f8361581c..51cde34882 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -33,15 +33,10 @@ import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground 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 -// NIP-F4 episodes carry no waveform tag, so every episode renders with the same flat baseline. -// Hoisted to a top-level constant so we share one List + WaveformData across the whole -// feed instead of allocating a fresh 96-element list per visible card. -private const val WAVEFORM_SAMPLES = 96 -private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) - // 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 // per-podcast list row stay pixel-identical. @@ -62,6 +57,17 @@ fun PodcastEpisodeAudioPlayer( accountViewModel: AccountViewModel, ) { val callbackUri = remember(note) { note.toNostrUri() } + // Use a real waveform when the episode ships one in its IMeta; NIP-F4 usually doesn't, so + // fall back to a decorative waveform seeded by the note id — same approach as music tracks, + // a per-episode silhouette instead of a flat strip. + val waveform = + remember(note) { + note.event + ?.getAudioMetaWithWaveform() + ?.waveform + ?.let { WaveformData(it) } + ?: syntheticWaveformFor(note.idHex) + } Row( PLAYER_HEIGHT_MODIFIER, @@ -77,7 +83,7 @@ fun PodcastEpisodeAudioPlayer( aspectRatio = null, proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), keepPlaying = false, - waveformData = FLAT_WAVEFORM, + waveformData = waveform, ) { mediaItem -> GetVideoController( mediaItem = mediaItem, @@ -87,7 +93,7 @@ fun PodcastEpisodeAudioPlayer( RenderVoicePlayer( mediaItem = mediaItem, controllerState = controller, - waveform = FLAT_WAVEFORM, + waveform = waveform, borderModifier = borderModifier, accountViewModel = accountViewModel, ) 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 7f7f3efd10..35f90c6444 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 @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -31,17 +30,20 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource 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.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.types.PodcastCoverCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -50,9 +52,9 @@ import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent /** - * Hero header for a single podcast screen: large cover art, title, websites and a tappable - * (collapse/expand) description, followed by the "Episodes (N)" section divider that the - * episode rows hang under. + * 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. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -61,11 +63,13 @@ fun PodcastHeader( metadataEvent: PodcastMetadataEvent?, 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 tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList } Column(Modifier.fillMaxWidth()) { PodcastCoverCard(image, metadataNote, accountViewModel) @@ -103,7 +107,21 @@ fun PodcastHeader( } } - description?.let { ExpandableDescription(it) } + description?.let { + val background = remember { mutableStateOf(Color.Transparent) } + + TranslatableRichTextViewer( + content = it, + canPreview = false, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = background, + id = metadataNote.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } } // Only render once episodes have actually loaded — avoids flashing "0 episodes" @@ -123,19 +141,3 @@ fun PodcastHeader( } } } - -@Composable -private fun ExpandableDescription(description: String) { - var expanded by remember(description) { mutableStateOf(false) } - - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - maxLines = if (expanded) Int.MAX_VALUE else 4, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - ) -} 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 d6bea463dc..af359a87f6 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 @@ -147,17 +147,17 @@ private fun PodcastScreenBody( PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav) is FeedState.Empty -> - PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) { + PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) { StatusText(stringRes(R.string.podcast_no_episodes)) } is FeedState.FeedError -> - PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) { + PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) { FeedError(state.errorMessage) { feedViewModel.invalidateData() } } is FeedState.Loading -> - PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel) { + PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) { Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } @@ -181,7 +181,7 @@ private fun PodcastEpisodesList( contentPadding = rememberFeedContentPadding(FeedPadding), ) { item("header") { - PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel) + PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav) } itemsIndexed( @@ -204,6 +204,7 @@ private fun PodcastHeaderWithStatus( metadataEvent: PodcastMetadataEvent?, listState: LazyListState, accountViewModel: AccountViewModel, + nav: INav, status: @Composable () -> Unit, ) { LazyColumn( @@ -211,7 +212,7 @@ private fun PodcastHeaderWithStatus( contentPadding = rememberFeedContentPadding(FeedPadding), ) { item("header") { - PodcastHeader(metadataNote, metadataEvent, null, accountViewModel) + PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav) } item("status") { status() } } From ba35a4e527b695cd9967c88e687ae7ff7a450633 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 21:30:44 +0000 Subject: [PATCH 4/4] feat(podcasts): translatable descriptions on the NoteCompose feed cards RenderPodcastMetadata and RenderPodcastEpisode rendered their description tag as a plain Text. Both now use TranslatableRichTextViewer (rich text + translation), matching the podcast screen header. The metadata card finally uses its previously-ignored canPreview/backgroundColor params; the episode card gives its short description a distinct id so it doesn't share remember state with the markdown body below it. --- .../amethyst/ui/note/types/PodcastEpisode.kt | 16 +++++++++----- .../amethyst/ui/note/types/PodcastMetadata.kt | 22 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 0187688979..faa0258569 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -104,12 +104,18 @@ fun RenderPodcastEpisode( } description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - maxLines = 3, - overflow = TextOverflow.Ellipsis, + val descriptionTags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } + + TranslatableRichTextViewer( + content = it, + canPreview = canPreview, + quotesLeft = 1, modifier = Modifier.fillMaxWidth(), + tags = descriptionTags, + backgroundColor = backgroundColor, + id = note.idHex + "-description", + accountViewModel = accountViewModel, + nav = nav, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index b9915f342e..c7f829c4c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -43,7 +43,9 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -58,8 +60,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent fun RenderPodcastMetadata( note: Note, makeItShort: Boolean, - @Suppress("UNUSED_PARAMETER") canPreview: Boolean, - @Suppress("UNUSED_PARAMETER") backgroundColor: MutableState, + canPreview: Boolean, + backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { @@ -99,12 +101,18 @@ fun RenderPodcastMetadata( } description?.takeIf { !makeItShort }?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - maxLines = 5, - overflow = TextOverflow.Ellipsis, + val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } + + TranslatableRichTextViewer( + content = it, + canPreview = canPreview, + quotesLeft = 1, modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, ) }