feat(podcasts): dedicated Podcast Bookmarks screen + detail-screen toggle

Adds a Podcasts row to the Bookmark lists screen (mirroring Git
Repositories), opening a feed of just the bookmarked podcasts:

- quartz: isPodcastEvent() — a reusable predicate matching shows,
  episodes (NIP-F4 + Podcasting-2.0) and trailers, used to pull the
  podcast subset out of the mixed NIP-51 kind:10003 bookmark list.
- BookmarkPodcastsFeedFilter / ...FeedViewModel / BookmarkedPodcastsScreen
  filter the bookmark list (public + private) down to podcast notes,
  newest first, and render them with the standard podcast cards.
- Route.BookmarkedPodcasts + AppNavigation wiring; a "Podcasts" row with a
  live count in ListOfBookmarkGroupsFeedView.
- The single-podcast detail screen (PodcastScreen) gains a bookmark action
  in its top bar that reflects and toggles bookmarked state (reusing the
  stateful PodcastBookmarkButton). TopBarExtensibleWithBackButton now
  accepts an actions slot to host it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-06-30 23:05:29 +00:00
parent ec3b41aa34
commit 496cda2f22
11 changed files with 242 additions and 1 deletions
@@ -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<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
composableFromEnd<Route.BookmarkedRepositories> { BookmarkedRepositoriesScreen(accountViewModel, nav) }
composableFromEnd<Route.BookmarkedPodcasts> { BookmarkedPodcastsScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
@@ -315,6 +315,8 @@ sealed class Route {
@Serializable object BookmarkedRepositories : Route()
@Serializable object BookmarkedPodcasts : Route()
@Serializable object BookmarkGroups : Route()
@Serializable object InterestSets : Route()
@@ -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,
)
}
@@ -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,
@@ -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,
@@ -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,
)
}
}
@@ -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<Note>() {
override fun feedKey(): String =
account.bookmarkState.bookmarks.value
.hashCode()
.toString()
override fun feed(): List<Note> {
val bookmarks = account.bookmarkState.bookmarks.value
return (bookmarks.public + bookmarks.private)
.filter { isPodcastEvent(it.event) }
.distinct()
.sortedByDescending { it.createdAt() ?: 0L }
}
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = BookmarkPodcastsFeedViewModel(account) as T
}
}
@@ -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,
)
},
+2
View File
@@ -1130,6 +1130,8 @@
<string name="public_bookmarks">Public Bookmarks</string>
<string name="repository_bookmarks">Repositories</string>
<string name="repository_bookmarks_explainer">Your bookmarked git repositories</string>
<string name="podcast_bookmarks">Podcasts</string>
<string name="podcast_bookmarks_explainer">Your bookmarked podcasts and episodes</string>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
<string name="add_to_public_bookmarks">Add to Public Bookmarks</string>
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
@@ -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