From 09a46b7faca0177dfc15156b6966dc7e50550663 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 20:36:42 +0000 Subject: [PATCH] feat(amethyst): add Episodes & Podcasts screens for NIP-F4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing Music/Playlists screen pair end-to-end so podcast events flow through the standard feed pipeline: - AccountSettings/Account/LocalPreferences gain the two new follow-list selectors (defaultPodcastEpisodesFollowList, defaultPodcastsFollowList) plus their derived liveX/liveXPerRelay flows. - AccountFeedContentStates wires podcastEpisodesFeed (kind 54 from LocalCache.notes) and podcastsFeed (kind 10154 from addressables) into updateFeedsWith/deleteNotes. - RelaySubscriptionsCoordinator + BottomBarFeedPreloaders register the two new filter assemblers, each with its own EOSE/since cursor. - Routes/AppNavigation/NavBarItem add the two destinations; both go in DrawerFeedsItems with PlayCircle and AudioFile icons (existing subset glyphs, no font regeneration required). - New NoteCompose dispatch cases call RenderPodcastEpisode (cover + audio player via the shared GetMediaItem/GetVideoController/ RenderVoicePlayer chain + description + markdown content) and RenderPodcastMetadata (cover + title + description + website chips). Skipped (separate PRs): authoring flows (NewPodcast/NewEpisode) and the kind:10054 favorites toggle sheet — podcast publishers typically don't hold their podcast keypair in Amethyst, and favorites need a PrivateTagArrayEventCache hookup in Account that's larger than the read path alone. --- .../amethyst/LocalPreferences.kt | 10 + .../vitorpamplona/amethyst/model/Account.kt | 6 + .../amethyst/model/AccountSettings.kt | 24 ++ .../RelaySubscriptionsCoordinator.kt | 6 + .../ui/feeds/RememberForeverStates.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../ui/navigation/bottombars/NavBarItem.kt | 18 ++ .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/note/NoteCompose.kt | 12 + .../amethyst/ui/note/types/PodcastEpisode.kt | 242 ++++++++++++++++++ .../amethyst/ui/note/types/PodcastMetadata.kt | 157 ++++++++++++ .../loggedIn/AccountFeedContentStates.kt | 8 + .../loggedIn/BottomBarFeedPreloaders.kt | 6 + .../podcasts/PodcastEpisodesFeedLoaded.kt | 76 ++++++ .../podcasts/PodcastEpisodesScreen.kt | 135 ++++++++++ .../podcasts/PodcastEpisodesTopBar.kt | 84 ++++++ .../loggedIn/podcasts/PodcastsFeedLoaded.kt | 74 ++++++ .../loggedIn/podcasts/PodcastsScreen.kt | 135 ++++++++++ .../loggedIn/podcasts/PodcastsTopBar.kt | 82 ++++++ .../podcasts/dal/PodcastEpisodesFeedFilter.kt | 88 +++++++ .../podcasts/dal/PodcastsFeedFilter.kt | 87 +++++++ .../PodcastEpisodesFilterAssembler.kt | 50 ++++ ...castEpisodesFilterAssemblerSubscription.kt | 48 ++++ .../datasource/PodcastEpisodesSubAssembler.kt | 96 +++++++ .../datasource/PodcastsFilterAssembler.kt | 50 ++++ .../PodcastsFilterAssemblerSubscription.kt | 48 ++++ .../datasource/PodcastsSubAssembler.kt | 96 +++++++ .../podcasts/datasource/SubAssemblyHelper.kt | 73 ++++++ .../FilterPodcastEventsByAuthors.kt | 103 ++++++++ .../FilterPodcastEventsByCommunities.kt | 144 +++++++++++ .../FilterPodcastEventsByFollows.kt | 45 ++++ .../FilterPodcastEventsByGeohashes.kt | 72 ++++++ .../FilterPodcastEventsByHashtag.kt | 69 +++++ .../FilterPodcastEventsGlobal.kt | 50 ++++ amethyst/src/main/res/values/strings.xml | 2 + 35 files changed, 2206 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index f4c3d2de3d..a6f28f0db2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -114,6 +114,8 @@ private object PrefKeys { const val DEFAULT_ARTICLES_FOLLOW_LIST = "defaultArticlesFollowList" const val DEFAULT_MUSIC_TRACKS_FOLLOW_LIST = "defaultMusicTracksFollowList" const val DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST = "defaultMusicPlaylistsFollowList" + const val DEFAULT_PODCAST_EPISODES_FOLLOW_LIST = "defaultPodcastEpisodesFollowList" + const val DEFAULT_PODCASTS_FOLLOW_LIST = "defaultPodcastsFollowList" const val DEFAULT_SOFTWARE_APPS_FOLLOW_LIST = "defaultSoftwareAppsFollowList" const val DEFAULT_BADGES_FOLLOW_LIST = "defaultBadgesFollowList" const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" @@ -383,6 +385,8 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value)) putString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicTracksFollowList.value)) putString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicPlaylistsFollowList.value)) + putString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastEpisodesFollowList.value)) + putString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastsFollowList.value)) putString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultSoftwareAppsFollowList.value)) putString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value)) putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) @@ -667,6 +671,8 @@ object LocalPreferences { defaultLongsFollowList = MutableStateFlow(followListPrefs.longs), defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks), defaultMusicPlaylistsFollowList = MutableStateFlow(followListPrefs.musicPlaylists), + defaultPodcastEpisodesFollowList = MutableStateFlow(followListPrefs.podcastEpisodes), + defaultPodcastsFollowList = MutableStateFlow(followListPrefs.podcasts), defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles), defaultSoftwareAppsFollowList = MutableStateFlow(followListPrefs.softwareApps), defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges), @@ -743,6 +749,8 @@ object LocalPreferences { val articles: TopFilter, val musicTracks: TopFilter, val musicPlaylists: TopFilter, + val podcastEpisodes: TopFilter, + val podcasts: TopFilter, val softwareApps: TopFilter, val badges: TopFilter, val browseEmojiSets: TopFilter, @@ -768,6 +776,8 @@ object LocalPreferences { articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows), musicTracks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, null), TopFilter.Global), musicPlaylists = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, null), TopFilter.Global), + podcastEpisodes = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, null), TopFilter.Global), + podcasts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, null), TopFilter.Global), softwareApps = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, null), TopFilter.Global), badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine), browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7f4c37e962..5efba5b745 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -520,6 +520,12 @@ class Account( val liveMusicPlaylistsFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList) val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow + val livePodcastEpisodesFollowLists: StateFlow = topNavFilterFlow(settings.defaultPodcastEpisodesFollowList) + val livePodcastEpisodesFollowListsPerRelay = OutboxLoaderState(livePodcastEpisodesFollowLists, cache, scope).flow + + val livePodcastsFollowLists: StateFlow = topNavFilterFlow(settings.defaultPodcastsFollowList) + val livePodcastsFollowListsPerRelay = OutboxLoaderState(livePodcastsFollowLists, cache, scope).flow + val liveSoftwareAppsFollowLists: StateFlow = topNavFilterFlow(settings.defaultSoftwareAppsFollowList) val liveSoftwareAppsFollowListsPerRelay = OutboxLoaderState(liveSoftwareAppsFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 361df02d97..3d2ed1f85b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -173,6 +173,8 @@ class AccountSettings( val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultMusicTracksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultMusicPlaylistsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultPodcastEpisodesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultPodcastsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultSoftwareAppsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -652,6 +654,28 @@ class AccountSettings( } } + fun changeDefaultPodcastEpisodesFollowList(name: FeedDefinition) { + changeDefaultPodcastEpisodesFollowList(name.code) + } + + fun changeDefaultPodcastEpisodesFollowList(name: TopFilter) { + if (defaultPodcastEpisodesFollowList.value != name) { + defaultPodcastEpisodesFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultPodcastsFollowList(name: FeedDefinition) { + changeDefaultPodcastsFollowList(name.code) + } + + fun changeDefaultPodcastsFollowList(name: TopFilter) { + if (defaultPodcastsFollowList.value != name) { + defaultPodcastsFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) { changeDefaultSoftwareAppsFollowList(name.code) } 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 099218e5b3..54522be719 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 @@ -54,6 +54,8 @@ 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.PodcastEpisodesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler @@ -127,6 +129,8 @@ class RelaySubscriptionsCoordinator( val articles = ArticlesFilterAssembler(client) val musicTracks = MusicTracksFilterAssembler(client) val musicPlaylists = MusicPlaylistsFilterAssembler(client) + val podcastEpisodes = PodcastEpisodesFilterAssembler(client) + val podcasts = PodcastsFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) @@ -162,6 +166,8 @@ class RelaySubscriptionsCoordinator( articles, musicTracks, musicPlaylists, + podcastEpisodes, + podcasts, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 15c288ba0a..c6d8f835d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -77,6 +77,8 @@ object ScrollStateKeys { const val ARTICLES_SCREEN = "ArticlesFeed" const val MUSIC_TRACKS_SCREEN = "MusicTracksFeed" const val MUSIC_PLAYLISTS_SCREEN = "MusicPlaylistsFeed" + const val PODCAST_EPISODES_SCREEN = "PodcastEpisodesFeed" + const val PODCASTS_SCREEN = "PodcastsFeed" const val SEARCH_SCREEN = "SearchFeed" 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 a6848bdcf2..ca2bcaf2fa 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 @@ -147,6 +147,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen 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.PodcastsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -289,6 +291,8 @@ fun BuildNavigation( composableFromEnd { ArticlesScreen(accountViewModel, nav) } composableFromEnd { MusicTracksScreen(accountViewModel, nav) } composableFromEnd { MusicPlaylistsScreen(accountViewModel, nav) } + composableFromEnd { PodcastEpisodesScreen(accountViewModel, nav) } + composableFromEnd { PodcastsScreen(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/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index bfdcc74dd7..2f48e8f2c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -56,6 +56,8 @@ enum class NavBarItem { SHORTS, MUSIC_TRACKS, MUSIC_PLAYLISTS, + PODCAST_EPISODES, + PODCASTS, PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, @@ -246,6 +248,20 @@ val NavBarCatalog: Map = icon = MaterialSymbols.AutoMirrored.PlaylistAdd, resolveRoute = { Route.MusicPlaylists }, ), + NavBarItem.PODCAST_EPISODES to + NavBarItemDef( + id = NavBarItem.PODCAST_EPISODES, + labelRes = R.string.route_podcast_episodes, + icon = MaterialSymbols.PlayCircle, + resolveRoute = { Route.PodcastEpisodes }, + ), + NavBarItem.PODCASTS to + NavBarItemDef( + id = NavBarItem.PODCASTS, + labelRes = R.string.route_podcasts, + icon = MaterialSymbols.AudioFile, + resolveRoute = { Route.Podcasts }, + ), NavBarItem.PUBLIC_CHATS to NavBarItemDef( id = NavBarItem.PUBLIC_CHATS, @@ -364,6 +380,8 @@ val DrawerFeedsItems: List = NavBarItem.SHORTS, NavBarItem.MUSIC_TRACKS, NavBarItem.MUSIC_PLAYLISTS, + NavBarItem.PODCAST_EPISODES, + NavBarItem.PODCASTS, NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, 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 0c8ef1fe7b..71c0d2c778 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 @@ -158,6 +158,10 @@ sealed class Route { @Serializable object MusicPlaylists : Route() + @Serializable object PodcastEpisodes : Route() + + @Serializable object Podcasts : Route() + @Serializable data class NewMusicTrack( val dTag: String? = null, 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 34cc57bfa8..e9ebff83ad 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 @@ -156,6 +156,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage @@ -315,6 +317,8 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -930,6 +934,14 @@ private fun RenderNoteRow( RenderMusicPlaylist(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is PodcastEpisodeEvent -> { + RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is PodcastMetadataEvent -> { + RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is DraftWrapEvent -> { RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) } 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 new file mode 100644 index 0000000000..def17de157 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -0,0 +1,242 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.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 +import androidx.compose.ui.layout.ContentScale +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.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.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +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.replyModifier +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent + +private val COVER_ASPECT_RATIO = 1f + +@Composable +fun RenderPodcastEpisode( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PodcastEpisodeEvent ?: return + + PodcastEpisodeBody( + noteEvent = noteEvent, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +private fun PodcastEpisodeBody( + noteEvent: PodcastEpisodeEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val description = remember(noteEvent) { noteEvent.description() } + // Pick the first audio URL. If a publisher provides multiple containers, this is the + // ordering they emitted — clients with codec preferences can extend this later. + val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } + // Suppress the markdown content block if it's blank (the title + description already + // describe a short episode); otherwise let RichText render it below. + val markdown = + remember(noteEvent) { + noteEvent.content.ifBlank { null } + } + + Column(MaterialTheme.colorScheme.replyModifier) { + PodcastEpisodeCover(image, note, accountViewModel) + firstAudio?.let { audio -> + // No waveform tag in NIP-F4, synthesize a flat baseline. ExoPlayer drives + // actual playback progress; the bars are purely decorative. + val waveform = remember(note.idHex) { flatWaveform() } + val callbackUri = remember(note) { note.toNostrUri() } + val playerBorder = + remember { + Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) + } + + 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 = waveform, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = waveform, + borderModifier = playerBorder, + accountViewModel = accountViewModel, + ) + } + } + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + markdown?.takeIf { !makeItShort }?.let { + Spacer(Modifier.padding(top = 4.dp)) + val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } + val callbackUri = remember(note) { note.toNostrUri() } + + TranslatableRichTextViewer( + content = it, + canPreview = canPreview, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +private fun PodcastEpisodeCover( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) + val imageModifier = + Modifier + .fillMaxWidth() + .aspectRatio(COVER_ASPECT_RATIO) + .clip(imageShape) + + Box(imageModifier) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, + onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, imageModifier) + } + } +} + +private const val WAVEFORM_SAMPLES = 96 + +private fun flatWaveform(): WaveformData = + // Even baseline — purely decorative since NIP-F4 doesn't carry waveform data. + WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) 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 new file mode 100644 index 0000000000..beb8bdbcbc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -0,0 +1,157 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +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.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +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 +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +private val COVER_ASPECT_RATIO = 1.0f + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderPodcastMetadata( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PodcastMetadataEvent ?: return + + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val description = remember(noteEvent) { noteEvent.description() } + val websites = remember(noteEvent) { noteEvent.websites() } + + Column(MaterialTheme.colorScheme.replyModifier) { + PodcastCover(image, note, accountViewModel) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + description?.takeIf { !makeItShort }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (websites.isNotEmpty() && !makeItShort) { + 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, + ) + } + } + } + } + } +} + +@Composable +private fun PodcastCover( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) + val imageModifier = + Modifier + .fillMaxWidth() + .aspectRatio(COVER_ASPECT_RATIO) + .clip(imageShape) + + Box(imageModifier) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, + onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, imageModifier) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 340b5417fb..b72c382605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -59,6 +59,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal.PictureFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastEpisodesFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.ClosedPollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.OpenPollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter @@ -116,6 +118,8 @@ class AccountFeedContentStates( val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) val musicTracksFeed = FeedContentState(MusicTracksFeedFilter(account), scope, LocalCache) val musicPlaylistsFeed = FeedContentState(MusicPlaylistsFeedFilter(account), scope, LocalCache) + val podcastEpisodesFeed = FeedContentState(PodcastEpisodesFeedFilter(account), scope, LocalCache) + val podcastsFeed = FeedContentState(PodcastsFeedFilter(account), scope, LocalCache) val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) @@ -207,6 +211,8 @@ class AccountFeedContentStates( articlesFeed.updateFeedWith(newNotes) musicTracksFeed.updateFeedWith(newNotes) musicPlaylistsFeed.updateFeedWith(newNotes) + podcastEpisodesFeed.updateFeedWith(newNotes) + podcastsFeed.updateFeedWith(newNotes) softwareAppsFeed.updateFeedWith(newNotes) calendarAppointmentsFeed.updateFeedWith(newNotes) @@ -265,6 +271,8 @@ class AccountFeedContentStates( articlesFeed.deleteFromFeed(newNotes) musicTracksFeed.deleteFromFeed(newNotes) musicPlaylistsFeed.deleteFromFeed(newNotes) + podcastEpisodesFeed.deleteFromFeed(newNotes) + podcastsFeed.deleteFromFeed(newNotes) softwareAppsFeed.deleteFromFeed(newNotes) calendarAppointmentsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 2336e72399..02063940a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilte import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription @@ -102,6 +104,10 @@ private fun PreloadFor( // tabs no matter which one the user pinned. -> MusicTracksFilterAssemblerSubscription(accountViewModel) + NavBarItem.PODCAST_EPISODES -> PodcastEpisodesFilterAssemblerSubscription(accountViewModel) + + NavBarItem.PODCASTS -> PodcastsFilterAssemblerSubscription(accountViewModel) + NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel) NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt new file mode 100644 index 0000000000..4abe4c7a9c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt @@ -0,0 +1,76 @@ +/* + * 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.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun PodcastEpisodesFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + // NoteCompose wraps the episode card with the standard reactions row + author chrome + // so zap/reply/etc. work the same as on the home feed. + NoteCompose( + baseNote = item, + modifier = Modifier, + isBoostedNote = false, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt new file mode 100644 index 0000000000..940282b58f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt @@ -0,0 +1,135 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +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.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastEpisodesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PodcastEpisodesScreen( + episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PodcastEpisodesScreen( + episodesFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(episodesFeedContentState) + WatchAccountForPodcastEpisodesScreen( + episodesFeedState = episodesFeedContentState, + accountViewModel = accountViewModel, + ) + // Drives the kind:54 REQ for the user's selected follow list — without it the screen + // only shows episodes already in cache from some other path (e.g. a nostr: link). + PodcastEpisodesFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PodcastEpisodesTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.PodcastEpisodes, nav, accountViewModel) { route -> + if (route == Route.PodcastEpisodes) { + episodesFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(episodesFeedContentState, true) { + SaveableFeedContentState(episodesFeedContentState, scrollStateKey = ScrollStateKeys.PODCAST_EPISODES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = episodesFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PodcastEpisodesFeed", + onLoaded = { loaded -> + PodcastEpisodesFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForPodcastEpisodesScreen( + episodesFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.livePodcastEpisodesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + episodesFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun PodcastEpisodesScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + PodcastEpisodesScreen( + episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt new file mode 100644 index 0000000000..ca40ecb546 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt @@ -0,0 +1,84 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastEpisodesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultPodcastEpisodesFollowList + .collectAsStateWithLifecycle() + + PodcastEpisodesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultPodcastEpisodesFollowList, + ) + } +} + +@Composable +private fun PodcastEpisodesTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + // Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3 + // Follows, Around Me, Global, people lists, interest sets, mute list. + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = onChange, + accountViewModel = accountViewModel, + ) +} + +@Preview +@Composable +private fun PodcastEpisodesTopBarPreview() { + ThemeComparisonColumn { + PodcastEpisodesTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt new file mode 100644 index 0000000000..55389e62f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt @@ -0,0 +1,74 @@ +/* + * 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.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun PodcastsFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + NoteCompose( + baseNote = item, + modifier = Modifier, + isBoostedNote = false, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt new file mode 100644 index 0000000000..8762972787 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -0,0 +1,135 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +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.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PodcastsScreen( + podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PodcastsScreen( + podcastsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(podcastsFeedContentState) + WatchAccountForPodcastsScreen( + podcastsFeedState = podcastsFeedContentState, + accountViewModel = accountViewModel, + ) + // Dedicated REQ for kind 10154 only — episode events come down through the episodes + // screen's own subscription, keeping each screen's `since` cursor isolated. + PodcastsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PodcastsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.Podcasts, nav, accountViewModel) { route -> + if (route == Route.Podcasts) { + podcastsFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(podcastsFeedContentState, true) { + SaveableFeedContentState(podcastsFeedContentState, scrollStateKey = ScrollStateKeys.PODCASTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = podcastsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PodcastsFeed", + onLoaded = { loaded -> + PodcastsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForPodcastsScreen( + podcastsFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.livePodcastsFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + podcastsFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun PodcastsScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + PodcastsScreen( + podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt new file mode 100644 index 0000000000..28dcbeb002 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt @@ -0,0 +1,82 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultPodcastsFollowList + .collectAsStateWithLifecycle() + + PodcastsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultPodcastsFollowList, + ) + } +} + +@Composable +private fun PodcastsTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = onChange, + accountViewModel = accountViewModel, + ) +} + +@Preview +@Composable +private fun PodcastsTopBarPreview() { + ThemeComparisonColumn { + PodcastsTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt new file mode 100644 index 0000000000..2a41a0e13a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -0,0 +1,88 @@ +/* + * 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.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent + +/** + * Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike + * music tracks/playlists which are addressable. Same follow-list + hidden/blocked + * gate as MusicTracksFeedFilter. + */ +class PodcastEpisodesFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "podcast-episodes-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultPodcastEpisodesFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.notes.filterIntoSet { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePodcastEpisodesFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } + + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + return noteEvent is PodcastEpisodeEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || 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/PodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt new file mode 100644 index 0000000000..742951b511 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt @@ -0,0 +1,87 @@ +/* + * 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.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in + * `LocalCache.addressables` — same shape as the music-playlists filter. + */ +class PodcastsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "podcasts-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultPodcastsFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePodcastsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } + + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + return noteEvent is PodcastMetadataEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || 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/datasource/PodcastEpisodesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt new file mode 100644 index 0000000000..19248cc6b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * 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.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PodcastEpisodesQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PodcastEpisodesFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + PodcastEpisodesSubAssembler(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/PodcastEpisodesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..5d0ab8998c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * 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 androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PodcastEpisodesFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PodcastEpisodesFilterAssemblerSubscription( + accountViewModel.dataSources().podcastEpisodes, + accountViewModel, + ) +} + +@Composable +fun PodcastEpisodesFilterAssemblerSubscription( + dataSource: PodcastEpisodesFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PodcastEpisodesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt new file mode 100644 index 0000000000..87eeb1a3ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt @@ -0,0 +1,96 @@ +/* + * 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.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PodcastEpisodesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PodcastEpisodesQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makePodcastEpisodesFilter(feedSettings, since, key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PodcastEpisodesQueryState) = key.account.userProfile() + + override fun list(key: PodcastEpisodesQueryState) = key.listName() + + fun PodcastEpisodesQueryState.listNameFlow() = account.settings.defaultPodcastEpisodesFollowList + + fun PodcastEpisodesQueryState.listName() = listNameFlow().value + + fun PodcastEpisodesQueryState.followsPerRelayFlow() = account.livePodcastEpisodesFollowListsPerRelay + + fun PodcastEpisodesQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PodcastEpisodesQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt new file mode 100644 index 0000000000..84b70f1989 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * 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.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PodcastsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PodcastsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + PodcastsSubAssembler(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/PodcastsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..4b8c44ab92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * 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 androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PodcastsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PodcastsFilterAssemblerSubscription( + accountViewModel.dataSources().podcasts, + accountViewModel, + ) +} + +@Composable +fun PodcastsFilterAssemblerSubscription( + dataSource: PodcastsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PodcastsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt new file mode 100644 index 0000000000..579e4cae88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt @@ -0,0 +1,96 @@ +/* + * 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.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PodcastsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PodcastsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makePodcastsFilter(feedSettings, since, key.feedStates.podcastsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PodcastsQueryState) = key.account.userProfile() + + override fun list(key: PodcastsQueryState) = key.listName() + + fun PodcastsQueryState.listNameFlow() = account.settings.defaultPodcastsFollowList + + fun PodcastsQueryState.listName() = listNameFlow().value + + fun PodcastsQueryState.followsPerRelayFlow() = account.livePodcastsFollowListsPerRelay + + fun PodcastsQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PodcastsQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.podcastsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..19b65c62aa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -0,0 +1,73 @@ +/* + * 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.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePodcastEpisodesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makePodcastFilter(feedSettings, PODCAST_EPISODE_KINDS, since, defaultSince) + +fun makePodcastsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) + +private fun makePodcastFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt new file mode 100644 index 0000000000..0feba30fba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -0,0 +1,103 @@ +/* + * 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.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +// Each podcast screen issues its own REQ keyed on its own follow-list selector — keep +// the kind lists split so episodes and show metadata never co-mingle in one filter. +internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND) +internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) + +fun filterPodcastEventsByAuthors( + relay: NormalizedRelayUrl, + kinds: List, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = kinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPodcastEventsByAuthors( + relay = it.key, + kinds = kinds, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPodcastEventsByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPodcastEventsByAuthors( + relay = it.key, + kinds = kinds, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt new file mode 100644 index 0000000000..697f3abf19 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -0,0 +1,144 @@ +/* + * 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.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +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.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPodcastEventsFromAllCommunities( + relay: NormalizedRelayUrl, + kinds: List, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + val kindsAsStrings = kinds.map { it.toString() } + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to kindsAsStrings, + ), + limit = communityList.size * 20, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("a" to communityList), + limit = communityList.size * 20, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPodcastEventsFromAllCommunities( + relay = it.key, + kinds = kinds, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} + +fun filterPodcastEventsFromCommunity( + relay: NormalizedRelayUrl, + kinds: List, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authorList = authors?.sorted() + val kindsAsStrings = kinds.map { it.toString() } + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to kindsAsStrings, + ), + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = kinds, + tags = mapOf("a" to listOf(community)), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPodcastEventsFromCommunity( + relay = it.key, + kinds = kinds, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt new file mode 100644 index 0000000000..0b573739d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.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.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPodcastEventsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { authors -> + filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt new file mode 100644 index 0000000000..f746865b0d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -0,0 +1,72 @@ +/* + * 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.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +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.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPodcastEventsByGeohashes( + relay: NormalizedRelayUrl, + kinds: List, + geotags: Set, + since: Long?, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPodcastEventsByGeohashes( + relay = it.key, + kinds = kinds, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt new file mode 100644 index 0000000000..124c464a17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -0,0 +1,69 @@ +/* + * 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.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +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.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPodcastEventsByHashtag( + relay: NormalizedRelayUrl, + kinds: List, + hashtags: Set, + since: Long? = null, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("t" to hashtags.sorted()), + limit = 200, + since = since, + ), + ), + ) + +fun filterPodcastEventsByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { + if (it.value.hashtags.isEmpty()) { + null + } else { + filterPodcastEventsByHashtag( + relay = it.key, + kinds = kinds, + hashtags = it.value.hashtags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt new file mode 100644 index 0000000000..e0fc0c4cba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -0,0 +1,50 @@ +/* + * 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.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterPodcastEventsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + // Podcasts are sparse on most relays today, so no `oneWeekAgo` floor — let the relay's + // own limit + kind filter bound the response. Mirrors the music-global rationale. + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = kinds, + limit = 200, + since = sinceForRelay, + ), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e5665979e9..c06d3b6217 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -764,6 +764,8 @@ Articles Music Playlists + Episodes + Podcasts New music track New playlist Title