mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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()
|
||||
|
||||
+2
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+55
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+48
@@ -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 }
|
||||
}
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
}
|
||||
+8
@@ -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,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user