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 118c70e0cb..bc929ff893 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 @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.BookmarkedPodcastsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen @@ -454,6 +455,7 @@ fun BuildNavigation( composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } composableFromEnd { BookmarkedRepositoriesScreen(accountViewModel, nav) } + composableFromEnd { BookmarkedPodcastsScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f228606eaf..928af18b7a 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 @@ -315,6 +315,8 @@ sealed class Route { @Serializable object BookmarkedRepositories : Route() + @Serializable object BookmarkedPodcasts : Route() + @Serializable object BookmarkGroups : Route() @Serializable object InterestSets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt index 99c2278462..ed5f975e6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt @@ -44,13 +44,14 @@ import com.vitorpamplona.amethyst.ui.theme.isLight fun TopBarExtensibleWithBackButton( title: @Composable RowScope.() -> Unit, extendableRow: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, popBack: () -> Unit, ) { MyExtensibleTopAppBar( title = title, extendableRow = extendableRow, navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } }, - actions = {}, + actions = actions, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 37caa116b7..ea4e410601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent import kotlinx.coroutines.flow.StateFlow @Composable @@ -66,6 +67,7 @@ fun ListOfBookmarkGroupsFeedView( openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, openRepositories: () -> Unit, + openPodcasts: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -99,6 +101,11 @@ fun ListOfBookmarkGroupsFeedView( HorizontalDivider(thickness = DividerThickness) } + item { + PodcastsBookmarkList(defaultBookmarks, openPodcasts) + HorizontalDivider(thickness = DividerThickness) + } + itemsIndexed( bookmarkGroupFeedState, key = { _: Int, item: LabeledBookmarkList -> item.identifier }, @@ -249,6 +256,54 @@ fun RepositoriesBookmarkList( ) } +@Composable +fun PodcastsBookmarkList( + defaultBookmarks: BookmarkListState, + openPodcasts: () -> Unit, +) { + val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle() + + // Podcasts live in the same kind:10003 list as everything else, so count the podcast subset. + val podcastCount = + (bookmarkState.public + bookmarkState.private).count { isPodcastEvent(it.event) } + + ListItem( + modifier = Modifier.clickable(onClick = openPodcasts), + headlineContent = { + Text(stringRes(R.string.podcast_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.podcast_bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Podcasts, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = podcastCount, + articleBookmarksSize = 0, + ) + } + }, + ) +} + @Composable fun OldBookmarkList( oldBookmarks: OldBookmarkListState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index e1aa912ebd..f76f075764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -66,6 +66,7 @@ fun ListOfBookmarkGroupsScreen( openOldBookmarks = { nav.nav(Route.OldBookmarks) }, openPinnedNotes = { nav.nav(Route.PinnedNotes) }, openRepositories = { nav.nav(Route.BookmarkedRepositories) }, + openPodcasts = { nav.nav(Route.BookmarkedPodcasts) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -110,6 +111,7 @@ fun ListOfBookmarkGroupsFeed( openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, openRepositories: () -> Unit, + openPodcasts: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -159,6 +161,7 @@ fun ListOfBookmarkGroupsFeed( openOldBookmarks = openOldBookmarks, openPinnedNotes = openPinnedNotes, openRepositories = openRepositories, + openPodcasts = openPodcasts, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt new file mode 100644 index 0000000000..f4b0531ef7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.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.bookmarkgroups.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal.BookmarkPodcastsFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun BookmarkedPodcastsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val podcastsFeedViewModel: BookmarkPodcastsFeedViewModel = + viewModel( + key = "NostrBookmarkPodcastsFeedViewModel", + factory = BookmarkPodcastsFeedViewModel.Factory(accountViewModel.account), + ) + + val bookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + + LaunchedEffect(bookmarks) { + podcastsFeedViewModel.invalidateData() + } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarWithBackButton(stringRes(id = R.string.podcast_bookmarks), nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableFeedView( + podcastsFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt new file mode 100644 index 0000000000..f9c83106da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.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.bookmarkgroups.podcasts.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent + +/** + * The podcast subset of the user's NIP-51 bookmark list (kind 10003): podcasts (shows and episodes) + * are bookmarked into the same general list as everything else, so this filter pulls just the + * podcast-typed notes ([isPodcastEvent]) back out — both public and private — newest first. + */ +class BookmarkPodcastsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List { + val bookmarks = account.bookmarkState.bookmarks.value + return (bookmarks.public + bookmarks.private) + .filter { isPodcastEvent(it.event) } + .distinct() + .sortedByDescending { it.createdAt() ?: 0L } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt new file mode 100644 index 0000000000..2c65ed9673 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * 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.bookmarkgroups.podcasts.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class BookmarkPodcastsFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(BookmarkPodcastsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkPodcastsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index 493747c106..1556c8cd95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.types.PodcastBookmarkButton import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel @@ -129,6 +130,13 @@ fun PodcastScreen( modifier = Modifier.weight(1f), ) }, + actions = { + // Only offer bookmarking once the show metadata has loaded — there must be a + // resolved event to add to the NIP-51 list. The button reflects bookmarked state. + if (show != null) { + PodcastBookmarkButton(metadataNote, accountViewModel) + } + }, popBack = nav::popBack, ) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 704c10645a..b8b2b2be5e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1130,6 +1130,8 @@ Public Bookmarks Repositories Your bookmarked git repositories + Podcasts + Your bookmarked podcasts and episodes Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt index 443316861d..f4a052e78b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nipXXPodcasting20.metadata import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode import com.vitorpamplona.quartz.podcasts.PodcastShow /** @@ -34,6 +36,16 @@ fun isPodcastShowEvent(event: Event?): Boolean = event is PodcastMetadataEvent || (event is AppSpecificDataEvent && event.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) +/** + * Whether [event] is any podcast event — a show ([isPodcastShowEvent]), an episode (NIP-F4 `kind:54` + * or Podcasting-2.0 `kind:30054`, both [PodcastEpisode]), or a Podcasting-2.0 trailer (`kind:30055`). + * Used to pull podcast items out of mixed lists (e.g. the NIP-51 bookmark list) for podcast-only views. + */ +fun isPodcastEvent(event: Event?): Boolean = + isPodcastShowEvent(event) || + event is PodcastEpisode || + event is Podcasting20TrailerEvent + /** * Adapts [event] to the spec-neutral [PodcastShow], or returns null if it is not a podcast show * (or its Podcasting-2.0 JSON content fails to parse). NIP-F4 metadata events implement