diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 9fc16904c8..f4c3d2de3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -112,6 +112,8 @@ private object PrefKeys { const val DEFAULT_NESTS_FOLLOW_LIST = "defaultNestsFollowList" const val DEFAULT_LONGS_FOLLOW_LIST = "defaultLongsFollowList" 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_SOFTWARE_APPS_FOLLOW_LIST = "defaultSoftwareAppsFollowList" const val DEFAULT_BADGES_FOLLOW_LIST = "defaultBadgesFollowList" const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" @@ -379,6 +381,8 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNestsFollowList.value)) putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value)) 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_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)) @@ -661,6 +665,8 @@ object LocalPreferences { defaultLiveStreamsFollowList = MutableStateFlow(followListPrefs.liveStreams), defaultNestsFollowList = MutableStateFlow(followListPrefs.nests), defaultLongsFollowList = MutableStateFlow(followListPrefs.longs), + defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks), + defaultMusicPlaylistsFollowList = MutableStateFlow(followListPrefs.musicPlaylists), defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles), defaultSoftwareAppsFollowList = MutableStateFlow(followListPrefs.softwareApps), defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges), @@ -735,6 +741,8 @@ object LocalPreferences { val nests: TopFilter, val longs: TopFilter, val articles: TopFilter, + val musicTracks: TopFilter, + val musicPlaylists: TopFilter, val softwareApps: TopFilter, val badges: TopFilter, val browseEmojiSets: TopFilter, @@ -758,6 +766,8 @@ object LocalPreferences { nests = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, null), TopFilter.Global), longs = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null), TopFilter.Global), 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), 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 ec7e117c5c..7f4c37e962 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -514,6 +514,12 @@ class Account( val liveArticlesFollowLists: StateFlow = topNavFilterFlow(settings.defaultArticlesFollowList) val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow + val liveMusicTracksFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicTracksFollowList) + val liveMusicTracksFollowListsPerRelay = OutboxLoaderState(liveMusicTracksFollowLists, cache, scope).flow + + val liveMusicPlaylistsFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList) + val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, 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 786529a6e7..361df02d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -171,6 +171,8 @@ class AccountSettings( val defaultNestsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), + val defaultMusicTracksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultMusicPlaylistsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultSoftwareAppsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -628,6 +630,28 @@ class AccountSettings( } } + fun changeDefaultMusicTracksFollowList(name: FeedDefinition) { + changeDefaultMusicTracksFollowList(name.code) + } + + fun changeDefaultMusicTracksFollowList(name: TopFilter) { + if (defaultMusicTracksFollowList.value != name) { + defaultMusicTracksFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultMusicPlaylistsFollowList(name: FeedDefinition) { + changeDefaultMusicPlaylistsFollowList(name.code) + } + + fun changeDefaultMusicPlaylistsFollowList(name: TopFilter) { + if (defaultMusicPlaylistsFollowList.value != name) { + defaultMusicPlaylistsFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) { changeDefaultSoftwareAppsFollowList(name.code) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 0d31ba6313..ae0555fc0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent @@ -3387,6 +3389,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is MusicTrackEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is MusicPlaylistEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is LnZapEvent -> { consume(event, relay, wasVerified) } 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 74a41668bb..099218e5b3 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 @@ -48,6 +48,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagF import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler @@ -123,6 +125,8 @@ class RelaySubscriptionsCoordinator( val nestRoomLiveness = NestRoomLivenessAssembler(client) val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) + val musicTracks = MusicTracksFilterAssembler(client) + val musicPlaylists = MusicPlaylistsFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) @@ -156,6 +160,8 @@ class RelaySubscriptionsCoordinator( nestRoomLiveness, longs, articles, + musicTracks, + musicPlaylists, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index ee6d0605a8..0a02216589 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent @@ -65,6 +67,8 @@ val SearchPostsByTextKinds1 = OldBookmarkListEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, PinListEvent.KIND, ZapPollEvent.KIND, ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index fbfe4e3216..125677c0c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -213,6 +213,14 @@ object MediaSaverToDisk { MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES } + cleanMimeType.startsWith("audio/", ignoreCase = true) -> { + // Audio content goes into the Music MediaStore + folder. Routing it through + // Video.EXTERNAL_CONTENT_URI (the previous fall-through behavior) crashes + // with IllegalArgumentException because MediaProvider rejects audio/* into + // the Video collection. + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_MUSIC + } + cleanMimeType.equals(PDF_MIME_TYPE, ignoreCase = true) -> { MediaStore.Downloads.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_DOWNLOADS } 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 a14f3bd526..15c288ba0a 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 @@ -75,6 +75,8 @@ object ScrollStateKeys { const val NESTS_SCREEN = "NestsFeed" const val LONGS_SCREEN = "LongsFeed" const val ARTICLES_SCREEN = "ArticlesFeed" + const val MUSIC_TRACKS_SCREEN = "MusicTracksFeed" + const val MUSIC_PLAYLISTS_SCREEN = "MusicPlaylistsFeed" 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 06091a298d..9973b10b1b 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 @@ -135,6 +135,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleL import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.LiveStreamsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.AddToMusicPlaylistSheet +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicTracksScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen @@ -281,6 +285,10 @@ fun BuildNavigation( composableFromEndArgs { NestLobbyScreen(it.addressValue, accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } composableFromEnd { ArticlesScreen(accountViewModel, nav) } + composableFromEnd { MusicTracksScreen(accountViewModel, nav) } + composableFromEnd { MusicPlaylistsScreen(accountViewModel, nav) } + composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } + composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } composable { ChessLobbyScreen(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 735b669e1e..bfdcc74dd7 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 @@ -54,6 +54,8 @@ enum class NavBarItem { CALENDARS, CALENDAR_COLLECTIONS, SHORTS, + MUSIC_TRACKS, + MUSIC_PLAYLISTS, PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, @@ -230,6 +232,20 @@ val NavBarCatalog: Map = icon = MaterialSymbols.PlayCircle, resolveRoute = { Route.Shorts }, ), + NavBarItem.MUSIC_TRACKS to + NavBarItemDef( + id = NavBarItem.MUSIC_TRACKS, + labelRes = R.string.route_music_tracks, + icon = MaterialSymbols.MusicNote, + resolveRoute = { Route.MusicTracks }, + ), + NavBarItem.MUSIC_PLAYLISTS to + NavBarItemDef( + id = NavBarItem.MUSIC_PLAYLISTS, + labelRes = R.string.route_music_playlists, + icon = MaterialSymbols.AutoMirrored.PlaylistAdd, + resolveRoute = { Route.MusicPlaylists }, + ), NavBarItem.PUBLIC_CHATS to NavBarItemDef( id = NavBarItem.PUBLIC_CHATS, @@ -346,6 +362,8 @@ val DrawerFeedsItems: List = NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SHORTS, + NavBarItem.MUSIC_TRACKS, + NavBarItem.MUSIC_PLAYLISTS, 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 f771a61300..7a9c63cbd0 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 @@ -142,6 +142,20 @@ sealed class Route { @Serializable object Articles : Route() + @Serializable object MusicTracks : Route() + + @Serializable object MusicPlaylists : Route() + + @Serializable + data class NewMusicTrack( + val dTag: String? = null, + ) : Route() + + @Serializable + data class AddToMusicPlaylist( + val trackAddress: String, + ) : Route() + @Serializable object Chess : Route() @Serializable object Wallet : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt index a49514dc17..84b19c084a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -114,6 +114,23 @@ fun SavingTopBar( additionalActions = additionalActions, ) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SendingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, + additionalActions: @Composable (() -> Unit)? = null, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.send, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, + additionalActions = additionalActions, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreatingTopBar( 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 4c3e3dfd08..34cc57bfa8 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 @@ -148,6 +148,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent @@ -215,6 +217,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease @@ -918,6 +922,14 @@ private fun RenderNoteRow( RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav) } + is MusicTrackEvent -> { + RenderMusicTrack(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is MusicPlaylistEvent -> { + RenderMusicPlaylist(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/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index c5b03619e4..cea9f92a61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -285,50 +286,70 @@ fun NoteDropDownMenu( } } } - // Emoji packs belong in the user's emoji list (kind 10030), not the bookmark list. - if (note.event is EmojiPackEvent) { - val emojiText = - if (state.isEmojiPackInMyList) { - stringRes(R.string.remove_from_emoji_list) - } else { - stringRes(R.string.add_to_emoji_list) + // Pick exactly one curation flow per kind: music tracks go to playlists, emoji + // packs go to the emoji list, everything else gets the standard bookmark rows. + // Showing both at once is noisy and makes "bookmark" feel like the catch-all when + // it really isn't for these kinds. + when { + note.event is MusicTrackEvent && note is AddressableNote -> { + // Music tracks (kind 36787) belong in playlists (kind 34139). The + // sheet behind this nav lets the user toggle membership across all of + // their own playlists in one place — that subsumes the private/public + // bookmark add+remove pair for this kind. + M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.add_to_music_playlist)) { + nav.nav(Route.AddToMusicPlaylist(note.address.toValue())) + onDismiss() } - M3ActionRow(icon = MaterialSymbols.EmojiEmotions, text = emojiText) { - val address = (note as AddressableNote).address - nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag)) - onDismiss() } - } else { - val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) - M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { - if (note.event is LongTextNoteEvent) { - nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) - } else { - nav.nav(Route.PostBookmarkManagement(note.idHex)) + + note.event is EmojiPackEvent -> { + // Emoji packs belong in the user's emoji list (kind 10030), not the + // bookmark list. + val emojiText = + if (state.isEmojiPackInMyList) { + stringRes(R.string.remove_from_emoji_list) + } else { + stringRes(R.string.add_to_emoji_list) + } + M3ActionRow(icon = MaterialSymbols.EmojiEmotions, text = emojiText) { + val address = (note as AddressableNote).address + nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag)) + onDismiss() } - onDismiss() } - } - if (state.isPrivateBookmarkNote) { - M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) { - accountViewModel.removePrivateBookmark(note) - onDismiss() - } - } else { - M3ActionRow(icon = MaterialSymbols.Lock, text = stringRes(R.string.add_to_private_bookmarks)) { - accountViewModel.addPrivateBookmark(note) - onDismiss() - } - } - if (state.isPublicBookmarkNote) { - M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { - accountViewModel.removePublicBookmark(note) - onDismiss() - } - } else { - M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { - accountViewModel.addPublicBookmark(note) - onDismiss() + + else -> { + val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) + M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { + if (note.event is LongTextNoteEvent) { + nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) + } else { + nav.nav(Route.PostBookmarkManagement(note.idHex)) + } + onDismiss() + } + if (state.isPrivateBookmarkNote) { + M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) { + accountViewModel.removePrivateBookmark(note) + onDismiss() + } + } else { + M3ActionRow(icon = MaterialSymbols.Lock, text = stringRes(R.string.add_to_private_bookmarks)) { + accountViewModel.addPrivateBookmark(note) + onDismiss() + } + } + if (state.isPublicBookmarkNote) { + M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { + accountViewModel.removePublicBookmark(note) + onDismiss() + } + } else { + M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { + accountViewModel.addPublicBookmark(note) + onDismiss() + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicFormatting.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicFormatting.kt new file mode 100644 index 0000000000..70c9814545 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicFormatting.kt @@ -0,0 +1,28 @@ +/* + * 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 + +/** `m:ss` formatting for a track length in seconds, shared by both MusicTrack and MusicPlaylist UI. */ +internal fun formatTrackDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt new file mode 100644 index 0000000000..1cea089e40 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -0,0 +1,755 @@ +/* + * 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.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImagePainter +import coil3.compose.rememberAsyncImagePainter +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +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.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.WatchAuthor +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageBanner +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageBannerBackground +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.Size55dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private val PLAYLIST_COVER_ASPECT_RATIO = 1f +private const val MAX_PREVIEW_TRACKS = 25 + +@Composable +fun RenderMusicPlaylist( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Observe so the card recomposes when a newer version of this playlist arrives from + // a relay (e.g. tracks added/removed, title changed). observeNoteEvent also drives + // the underlying EventFinderFilterAssembler subscription, so the playlist body itself + // gets fetched from relays if only the address was known. + val noteEvent by observeNoteEvent(note, accountViewModel) + val event = noteEvent ?: return + + MusicPlaylistHeader( + noteEvent = event, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun MusicPlaylistHeader( + noteEvent: MusicPlaylistEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + // Some publishing tools copy the same string into both the `description` tag and the JSON + // `content` field. Dedupe so we only show the long-form once: keep `content` (the + // richer of the two — Markdown / NIP-19 references render through TranslatableRichTextViewer + // below) and suppress the short description when it would just duplicate it. + val rawShortDescription = remember(noteEvent) { noteEvent.description() } + val longDescription = remember(noteEvent) { noteEvent.content.ifBlank { null } } + val shortDescription = + remember(rawShortDescription, longDescription) { + rawShortDescription?.takeUnless { short -> + longDescription != null && short.trim().equals(longDescription.trim(), ignoreCase = true) + } + } + val trackAddresses = remember(noteEvent) { noteEvent.trackAddresses() } + val isCollaborative = remember(noteEvent) { noteEvent.isCollaborative() } + val isPrivate = remember(noteEvent) { noteEvent.isPrivate() } + + Column(MaterialTheme.colorScheme.replyModifier) { + MusicPlaylistCover(image, note, trackAddresses.size, 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(), + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.AutoMirrored.PlaylistAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.padding(start = 6.dp)) + val trackCount = trackAddresses.size + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count, trackCount, trackCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + if (isCollaborative) { + PlaylistTag(text = stringRes(R.string.music_playlist_collaborative)) + } + if (isPrivate) { + PlaylistTag(text = stringRes(R.string.music_playlist_private)) + } + } + + shortDescription?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + longDescription?.takeIf { !makeItShort }?.let { + 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, + ) + } + + if (trackAddresses.isNotEmpty()) { + Spacer(Modifier.padding(top = 4.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + trackAddresses + .take(MAX_PREVIEW_TRACKS) + .forEachIndexed { index, address -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + ) + } + LoadAddressableNote(address, accountViewModel) { trackNote -> + if (trackNote != null) { + PlaylistTrackRow( + position = index + 1, + trackNote = trackNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + MissingPlaylistTrackRow(position = index + 1) + } + } + } + + if (trackAddresses.size > MAX_PREVIEW_TRACKS) { + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + val remaining = trackAddresses.size - MAX_PREVIEW_TRACKS + Text( + text = pluralStringResource(R.plurals.music_playlist_more_tracks, remaining, remaining), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + } + } + } + } +} + +@Composable +private fun MusicPlaylistCover( + image: String?, + note: Note, + trackCount: Int, + accountViewModel: AccountViewModel, +) { + val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) + val imageModifier = + Modifier + .fillMaxWidth() + .aspectRatio(PLAYLIST_COVER_ASPECT_RATIO) + .clip(imageShape) + + Box(imageModifier) { + if (image == null) { + // No artwork at all: banner + author avatar + chip side-by-side at the bottom. + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) + } else { + // Drive the painter directly so we can layer the loaded image and the chip as + // separate siblings of the same Box — using SubcomposeAsyncImage's content-lambda + // confused its layout (the chip's wrap-content Box was stretched to the cover's + // full size). A plain Image + painter-state observation behaves predictably. + val painter = rememberAsyncImagePainter(model = image) + val state by painter.state.collectAsState() + + when (state) { + is AsyncImagePainter.State.Success -> { + Image( + painter = painter, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + TrackCountChip( + trackCount = trackCount, + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + ) + } + + is AsyncImagePainter.State.Error -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) + } + + else -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = true) + } + } + } + } +} + +/** + * Author-banner cover used whenever the playlist has no album artwork to show — either + * because the playlist doesn't carry an `image` tag, or because the configured URL is + * still loading or failed to load. Lays out the author's avatar and the track-count chip + * side-by-side at the bottom-left so the chip never overlaps the avatar. + */ +@Composable +private fun BannerWithAuthorChip( + note: Note, + trackCount: Int, + imageModifier: Modifier, + accountViewModel: AccountViewModel, + blurred: Boolean, +) { + Box(imageModifier) { + if (blurred) { + DefaultImageBannerBackground(note, accountViewModel, imageModifier) + } else { + DefaultImageBanner(note, accountViewModel, imageModifier) + } + + WatchAuthor(baseNote = note, accountViewModel = accountViewModel) { author -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(10.dp), + ) { + BaseUserPicture(author, Size55dp, accountViewModel, Modifier) + Spacer(Modifier.padding(start = 8.dp)) + TrackCountChip(trackCount = trackCount) + } + } + } +} + +@Composable +private fun TrackCountChip( + trackCount: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(RoundedCornerShape(10.dp)) + .background(Color.Black.copy(alpha = 0.55f)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.AutoMirrored.PlaylistAdd, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.padding(start = 6.dp)) + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count, trackCount, trackCount), + style = MaterialTheme.typography.bodySmall, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun PlaylistTrackRow( + position: Int, + trackNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Observe the track note so each row recomposes when the track event arrives from a + // relay (cards switch from "Unknown track" placeholder to the real title/artist/cover) + // or a newer revision replaces the cached one. observeNoteEvent also drives the + // EventFinderFilterAssembler subscription, so the row pulls the track itself if only + // the address is known. + val trackEvent by observeNoteEvent(trackNote, accountViewModel) + val title = trackEvent?.title() ?: stringRes(R.string.music_playlist_unknown_track) + val artist = trackEvent?.artist() + val duration = trackEvent?.duration() + val cover = trackEvent?.image() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Note(trackNote.idHex)) } + .padding(vertical = 8.dp), + ) { + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .width(28.dp) + .padding(end = 4.dp), + ) + + if (cover != null) { + MyAsyncImage( + imageUrl = cover, + contentDescription = null, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + loadedImageModifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + accountViewModel = accountViewModel, + onLoadingBackground = { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + ) + }, + onError = { TrackCoverPlaceholder() }, + ) + } else { + TrackCoverPlaceholder() + } + + Spacer(Modifier.padding(start = 10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + artist?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + duration?.let { + Spacer(Modifier.padding(start = 6.dp)) + Text( + text = formatTrackDuration(it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) + } + } +} + +@Composable +private fun MissingPlaylistTrackRow(position: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .width(28.dp) + .padding(end = 4.dp), + ) + TrackCoverPlaceholder() + Spacer(Modifier.padding(start = 10.dp)) + Text( + text = stringRes(R.string.music_playlist_loading_track), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun TrackCoverPlaceholder() { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun PlaylistTag(text: String) { + Spacer(Modifier.padding(start = 8.dp)) + Box( + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } +} + +// --------------------------------------------------------------------------- +// @Preview composables. Constructs a MusicPlaylistEvent plus a handful of +// MusicTrackEvents it references, pushes them through LocalCache, then +// renders the same code path the production feed uses. The cover image URL +// is unreachable on purpose so the cover falls back to the deterministic +// banner + author-avatar layout — that's what users see while the real +// cover is still loading. +// --------------------------------------------------------------------------- + +private const val PREVIEW_PLAYLIST_PUBKEY = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799" +private val PREVIEW_PLAYLIST_SIG = "0".repeat(128) + +private fun previewPlaylistHexId(prefix: String): String { + val mixed = (prefix.hashCode().toLong() and 0xFFFFFFFFL).toString(16) + return mixed.padStart(64, 'a').take(64) +} + +private fun previewPlaylistTrack( + dTag: String, + title: String, + artist: String, + duration: Int, +): MusicTrackEvent = + MusicTrackEvent( + id = previewPlaylistHexId("ptrack-$dTag-$title"), + pubKey = PREVIEW_PLAYLIST_PUBKEY, + createdAt = 1_730_000_000L, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("title", title), + arrayOf("artist", artist), + arrayOf("url", "https://example.invalid/$dTag.mp3"), + arrayOf("duration", duration.toString()), + arrayOf("t", "music"), + ), + content = "", + sig = PREVIEW_PLAYLIST_SIG, + ) + +private fun previewPlaylistEvent( + dTag: String, + title: String, + description: String, + tracks: List, + image: String? = "https://example.invalid/playlist.jpg", + isCollaborative: Boolean = false, + isPrivate: Boolean = false, + content: String = "", +): MusicPlaylistEvent { + val tags = + buildList { + add(arrayOf("d", dTag)) + add(arrayOf("title", title)) + add(arrayOf("description", description)) + image?.let { add(arrayOf("image", it)) } + add(arrayOf("t", "playlist")) + tracks.forEach { track -> + add(arrayOf("a", "${MusicTrackEvent.KIND}:${track.pubKey}:${track.dTag()}")) + } + if (isPrivate) add(arrayOf("private", "true")) else add(arrayOf("public", "true")) + if (isCollaborative) add(arrayOf("collaborative", "true")) + }.toTypedArray() + + return MusicPlaylistEvent( + id = previewPlaylistHexId("pl-$dTag-$title"), + pubKey = PREVIEW_PLAYLIST_PUBKEY, + createdAt = 1_730_000_000L, + tags = tags, + content = content, + sig = PREVIEW_PLAYLIST_SIG, + ) +} + +@Preview +@Composable +private fun RenderMusicPlaylistPreview() { + val tracks = + listOf( + previewPlaylistTrack("summer-nights-2024", "Summer Nights", "The Midnight Collective", 245), + previewPlaylistTrack("sunset-dreams", "Sunset Dreams", "Pacific Wave", 198), + previewPlaylistTrack("ocean-breeze", "Ocean Breeze", "The Midnight Collective", 312), + ) + val event = + previewPlaylistEvent( + dTag = "summer-vibes-2024", + title = "Summer Vibes 2024", + description = "Chill electronic tracks for summer", + tracks = tracks, + content = "My favorite summer vibes from 2024", + ) + + remember(event) { + runBlocking { + withContext(Dispatchers.IO) { + tracks.forEach { LocalCache.justConsume(it, null, true) } + LocalCache.justConsume(event, null, true) + } + } + } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { + RenderMusicPlaylist( + note = it, + makeItShort = false, + canPreview = true, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } + } + } +} + +@Preview +@Composable +private fun RenderMusicPlaylistCollaborativePrivatePreview() { + val event = + previewPlaylistEvent( + dTag = "friends-collab", + title = "Friends Collab", + description = "Everyone adds their picks", + tracks = emptyList(), + isPrivate = true, + isCollaborative = true, + content = "", + ) + + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { + RenderMusicPlaylist( + note = it, + makeItShort = false, + canPreview = true, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } + } + } +} + +@Preview +@Composable +private fun MusicPlaylistCoverPreview() { + val event = + previewPlaylistEvent( + dTag = "cover-only", + title = "Cover Only", + description = "", + tracks = emptyList(), + image = null, + ) + + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { + MusicPlaylistCover( + image = null, + note = it, + trackCount = 7, + accountViewModel = mockAccountViewModel(), + ) + } + } + } +} + +@Preview +@Composable +private fun MissingPlaylistTrackRowPreview() { + ThemeComparisonColumn { + MissingPlaylistTrackRow(position = 4) + } +} + +@Preview +@Composable +private fun TrackCoverPlaceholderPreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp)) { + TrackCoverPlaceholder() + } + } +} + +@Preview +@Composable +private fun PlaylistTagPreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + PlaylistTag(text = "Collaborative") + PlaylistTag(text = "Private") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt new file mode 100644 index 0000000000..3b20bba162 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -0,0 +1,641 @@ +/* + * 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.background +import androidx.compose.foundation.clickable +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.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.layout.size +import androidx.compose.foundation.shape.CircleShape +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.mutableStateOf +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView +import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground +import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +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.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +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.Font10SP +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private val COVER_ASPECT_RATIO = 1f + +// Content text prefixes that some music-publishing tools auto-fill — purely restate the +// title/artist that the card already shows above. Suppress the description block in that case +// so the card doesn't waste a paragraph echoing its own header. +private val MUSIC_TRACK_BOILERPLATE_PREFIXES = + listOf( + "Listen to my song", + ) + +@Composable +fun RenderMusicTrack( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? MusicTrackEvent ?: return + + MusicTrackHeader( + noteEvent = noteEvent, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MusicTrackHeader( + noteEvent: MusicTrackEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val artist = remember(noteEvent) { noteEvent.artist() } + val url = remember(noteEvent) { noteEvent.url() } + val videoUrl = remember(noteEvent) { noteEvent.videoUrl() } + val image = remember(noteEvent) { noteEvent.image() } + val album = remember(noteEvent) { noteEvent.album() } + val trackNumber = remember(noteEvent) { noteEvent.trackNumber() } + val released = remember(noteEvent) { noteEvent.released() } + val duration = remember(noteEvent) { noteEvent.duration() } + val format = remember(noteEvent) { noteEvent.format() } + val isExplicit = remember(noteEvent) { noteEvent.isExplicit() } + // Suppress publishing-tool boilerplate that just restates the title/artist already shown + // above. Today the only known offender is "Listen to my song ..." (emitted by some Blossom + // uploaders); extend MUSIC_TRACK_BOILERPLATE_PREFIXES if more turn up. + val description = + remember(noteEvent) { + noteEvent.content + .takeUnless { content -> + MUSIC_TRACK_BOILERPLATE_PREFIXES.any { content.trim().startsWith(it, ignoreCase = true) } + }?.ifBlank { null } + } + val topics = + remember(noteEvent) { + noteEvent.tags + .mapNotNull { if (it.size > 1 && it[0] == "t" && it[1] != MusicTrackEvent.GENRE_TAG) it[1] else null } + .distinct() + .take(4) + .toImmutableList() + } + // Per spec, `format` is the AUDIO format (mp3/flac/m4a/ogg) — only meaningful on the + // audio path. The video path lets ExoPlayer sniff the container. + val audioMimeType = remember(format) { format?.let { "audio/$it" } } + + Column(MaterialTheme.colorScheme.replyModifier) { + if (videoUrl != null) { + // A track with a `video` tag is an actual music-video file — show it through the + // full video player (with the album cover as thumb when we have one). + if (image != null) { + LoadThumbAndThenVideoView( + videoUri = videoUrl, + mimeType = null, + title = title, + thumbUri = image, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } else { + VideoView( + videoUri = videoUrl, + mimeType = null, + title = title, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + } else { + // Audio-only path. We *don't* call RenderAudioWithWaveform here for two reasons: + // 1. It wraps the player in `MaxWidthPaddingTop5dp` and renders its own + // DisplayUncitedHashtags row. We need the player to butt directly against + // the cover (no gap) and we already render hashtags as TopicChips below, + // so the inner ones would duplicate. + // 2. It hard-codes the all-rounded `imageModifier` as the player border. The + // music card wants the cover's top corners + the player's bottom corners + // to read as one continuous unit. + // So we inline the GetMediaItem → GetVideoController → RenderVoicePlayer chain + // and supply a bottom-rounded border. ExoPlayer logic, controls, and PiP all + // come from the shared composables — only the chrome differs. + MusicTrackCover(image, note, accountViewModel) + if (url != null) { + // Spec doesn't carry a waveform for kind 36787, so we synthesize one keyed + // off the track address. The bars are purely decorative — ExoPlayer owns + // actual playback progress. + val syntheticWaveform = remember(note.idHex) { syntheticWaveformFor(note.idHex) } + val callbackUri = remember(note) { note.toNostrUri() } + val playerBorder = + remember { + Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) + } + + Row( + // 80dp shrinks the visible top/bottom border around the 75dp play + // button to ~2.5dp on each side — the default 100dp inherited from + // VoiceHeightModifier left a 12.5dp empty band that the user flagged + // as too tall on a music card (where the player butts up against + // the cover and there's no breathing room needed). + Modifier.fillMaxWidth().height(80.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = url, + title = title, + artworkUri = null, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + mimeType = audioMimeType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(url), + keepPlaying = false, + waveformData = syntheticWaveform, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = syntheticWaveform, + 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(), + ) + } + + artist?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.padding(start = 6.dp)) + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + + if (album != null || released != null || duration != null || isExplicit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + album?.let { + val trackPart = trackNumber?.let { n -> " · #$n" } ?: "" + MetaText(text = it + trackPart) + } + if (album != null && (released != null || duration != null || isExplicit)) MetaSeparator() + released?.let { MetaText(text = it) } + if (released != null && (duration != null || isExplicit)) MetaSeparator() + duration?.let { MetaText(text = formatTrackDuration(it)) } + if (duration != null && isExplicit) MetaSeparator() + if (isExplicit) ExplicitBadge() + } + } + + description?.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, + ) + // No DisplayUncitedHashtags here — the TopicChip FlowRow below already shows + // every non-genre-marker hashtag, and stacking both produces visually identical + // duplicate chip rows. + } + + if (topics.isNotEmpty()) { + Spacer(Modifier.padding(top = 4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + topics.forEach { topic -> + TopicChip(topic) { nav.nav(Route.Hashtag(topic.lowercase())) } + } + } + } + } + } +} + +@Composable +private fun MusicTrackCover( + 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) + } + } +} + +@Composable +private fun MetaText(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun MetaSeparator() { + Text( + text = " · ", + color = MaterialTheme.colorScheme.grayText, + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun ExplicitBadge() { + Text( + text = "E", + style = MaterialTheme.typography.labelSmall, + fontSize = Font10SP, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) +} + +@Composable +private fun TopicChip( + topic: String, + onClick: (() -> Unit)? = null, +) { + // clip→clickable→background→padding so the tap target is the same as the visible chip + // and the Material ripple stays inside the rounded shape. + val base = + Modifier + .clip(CircleShape) + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 2.dp) + Text( + text = "#$topic", + style = MaterialTheme.typography.labelSmall, + fontSize = Font10SP, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = base, + ) +} + +private const val SYNTHETIC_WAVEFORM_SAMPLES = 96 +private const val TWO_PI = (Math.PI * 2).toFloat() + +/** + * Builds a stable, decorative waveform from the track id. ExoPlayer drives actual playback + * progress — these bars only exist so the audio player has the familiar "music silhouette" + * shape rather than a flat strip when the spec has no `waveform` tag. + * + * Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter + * envelope) is drawn from the seeded RNG before the per-bar loop, so two different track ids + * produce visibly different waveforms — earlier versions used a fixed envelope + carrier that + * only varied by per-bar noise, which made every track look like the same slow-fade sine with + * minor wiggle. + */ +private fun syntheticWaveformFor(seed: String): WaveformData { + // Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide + // (rare for hex addresses but cheap to defend against) still differ via the bit shuffle. + val rng = kotlin.random.Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong()) + + // Shape parameters drawn once per track — these are what makes track A look different + // from track B at a glance. + val phaseOffset = rng.nextFloat() * TWO_PI + val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.5–8 full cycles across the strip + val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.18–0.36 + val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.28–0.50 + val envelopeStrength = rng.nextFloat() * 0.45f // 0.0–0.45; some tracks fade in/out, others don't + val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.18–0.40 + + val bars = + List(SYNTHETIC_WAVEFORM_SAMPLES) { index -> + val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES + // Optional gentle fade so some tracks taper at the ends, others stay even. + val envelope = 1f - envelopeStrength * (1f - kotlin.math.sin(phase * Math.PI).toFloat()) + val carrier = kotlin.math.sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight + val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength + ((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f) + } + return WaveformData(bars) +} + +// --------------------------------------------------------------------------- +// @Preview composables. These wire a constructed MusicTrackEvent through +// LocalCache so the renderer pulls the real Note path it would in production. +// The audio/image URLs are intentionally unreachable — VideoView falls back to +// its tap-to-load thumbnail state, and MyAsyncImage falls back to the +// DefaultImageHeader robohash. That's the same first-paint state real users +// see for a track they haven't tapped to download yet, which is what the +// preview is meant to show. +// --------------------------------------------------------------------------- + +private const val PREVIEW_TRACK_PUBKEY = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799" +private val PREVIEW_TRACK_SIG = "0".repeat(128) + +// Deterministic 64-char hex from any string — Event ids must be hex even for previews so +// downstream consumers that validate the format don't blow up. Not cryptographic; just +// reproducible per (preview, dTag, title) so the LocalCache slot stays stable across recomposes. +private fun previewHexId(prefix: String): String { + val mixed = (prefix.hashCode().toLong() and 0xFFFFFFFFL).toString(16) + return mixed.padStart(64, 'a').take(64) +} + +private fun previewMusicTrackEvent( + dTag: String, + title: String, + artist: String, + album: String? = null, + duration: Int? = null, + released: String? = null, + trackNumber: Int? = null, + explicit: Boolean = false, + image: String? = "https://example.invalid/cover.jpg", + url: String = "https://example.invalid/track.mp3", + content: String = "", +): MusicTrackEvent { + val tags = + buildList { + add(arrayOf("d", dTag)) + add(arrayOf("title", title)) + add(arrayOf("artist", artist)) + add(arrayOf("url", url)) + add(arrayOf("t", "music")) + image?.let { add(arrayOf("image", it)) } + album?.let { add(arrayOf("album", it)) } + trackNumber?.let { add(arrayOf("track_number", it.toString())) } + released?.let { add(arrayOf("released", it)) } + duration?.let { add(arrayOf("duration", it.toString())) } + if (explicit) add(arrayOf("explicit", "true")) + }.toTypedArray() + + return MusicTrackEvent( + id = previewHexId("track-$dTag-$title"), + pubKey = PREVIEW_TRACK_PUBKEY, + createdAt = 1_730_000_000L, + tags = tags, + content = content, + sig = PREVIEW_TRACK_SIG, + ) +} + +@Preview +@Composable +private fun RenderMusicTrackPreview() { + val event = + previewMusicTrackEvent( + dTag = "summer-nights-2024", + title = "Summer Nights", + artist = "The Midnight Collective", + album = "Endless Summer", + duration = 245, + released = "2024-06-15", + trackNumber = 3, + explicit = false, + content = "Lyrics:\n[Verse 1]\nWalking through the city lights...\n\nProducer: John Doe", + ) + + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { + RenderMusicTrack( + note = it, + makeItShort = false, + canPreview = true, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } + } + } +} + +@Preview +@Composable +private fun RenderMusicTrackExplicitPreview() { + val event = + previewMusicTrackEvent( + dTag = "loud-and-clear", + title = "Loud and Clear", + artist = "Static Riot", + album = "Distortion", + duration = 187, + explicit = true, + content = "", + ) + + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { + RenderMusicTrack( + note = it, + makeItShort = false, + canPreview = true, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } + } + } +} + +@Preview +@Composable +private fun MusicTrackCoverPreview() { + val event = previewMusicTrackEvent(dTag = "cover-only", title = "Cover Only", artist = "Anon") + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { MusicTrackCover(image = null, note = it, accountViewModel = mockAccountViewModel()) } + } + } +} + +@Preview +@Composable +private fun ExplicitBadgePreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp)) { + ExplicitBadge() + } + } +} + +@Preview +@Composable +private fun TopicChipPreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp)) { + TopicChip("electronic") + Spacer(Modifier.padding(start = 4.dp)) + TopicChip("synthwave") + Spacer(Modifier.padding(start = 4.dp)) + TopicChip("80s") + } + } +} 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 42555a177c..340b5417fb 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 @@ -51,6 +51,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.dal.LiveStreamsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.dal.LongsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicPlaylistsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicTracksFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState @@ -112,6 +114,8 @@ class AccountFeedContentStates( val nestsFeed = FeedContentState(NestsFeedFilter(account), scope, LocalCache) val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache) val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) + val musicTracksFeed = FeedContentState(MusicTracksFeedFilter(account), scope, LocalCache) + val musicPlaylistsFeed = FeedContentState(MusicPlaylistsFeedFilter(account), scope, LocalCache) val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) @@ -201,6 +205,8 @@ class AccountFeedContentStates( nestsFeed.updateFeedWith(newNotes) longsFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes) + musicTracksFeed.updateFeedWith(newNotes) + musicPlaylistsFeed.updateFeedWith(newNotes) softwareAppsFeed.updateFeedWith(newNotes) calendarAppointmentsFeed.updateFeedWith(newNotes) @@ -257,6 +263,8 @@ class AccountFeedContentStates( nestsFeed.deleteFromFeed(newNotes) longsFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes) + musicTracksFeed.deleteFromFeed(newNotes) + musicPlaylistsFeed.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 8af58972f8..2336e72399 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 @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription +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.polls.datasource.PollsFilterAssemblerSubscription @@ -95,6 +96,12 @@ private fun PreloadFor( NavBarItem.SHORTS -> ShortsFilterAssemblerSubscription(accountViewModel) + NavBarItem.MUSIC_TRACKS, + NavBarItem.MUSIC_PLAYLISTS, + // Same REQ fetches both kinds 36787 + 34139, so one subscription serves both + // tabs no matter which one the user pinned. + -> MusicTracksFilterAssemblerSubscription(accountViewModel) + NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel) NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index 3b97682e7c..49866f79ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -54,6 +56,8 @@ class FollowPackFeedNewThreadFeedFilter( val ADDRESSABLE_KINDS = listOf( AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, @@ -135,6 +139,8 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || + noteEvent is MusicTrackEvent || + noteEvent is MusicPlaylistEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 723b1a4373..f2b7319928 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -78,7 +80,9 @@ class GeoHashFeedFilter( event is ChannelMessageEvent || event is PrivateDmEvent || event is ZapPollEvent || - event is AudioHeaderEvent + event is AudioHeaderEvent || + event is MusicTrackEvent || + event is MusicPlaylistEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 4c02ec0a5e..257dba9cc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -48,6 +50,8 @@ val PostsByGeohashKinds = HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, WikiNoteEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index f5fc6b4974..daf3a81470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -82,7 +84,9 @@ class HashtagFeedFilter( event is ChannelMessageEvent || event is PrivateDmEvent || event is ZapPollEvent || - event is AudioHeaderEvent + event is AudioHeaderEvent || + event is MusicTrackEvent || + event is MusicPlaylistEvent ) && event.isTaggedHash(hashTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 3c4528c715..ca2b17014b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Commen import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -60,6 +62,8 @@ val PostsByHashtagKinds2 = InteractiveStorySceneEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 1373fca9ca..1e7f0dba26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -57,6 +59,8 @@ class HomeNewThreadFeedFilter( val ADDRESSABLE_KINDS = listOf( AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, ClassifiedsEvent.KIND, @@ -125,6 +129,8 @@ class HomeNewThreadFeedFilter( noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || + noteEvent is MusicTrackEvent || + noteEvent is MusicPlaylistEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt new file mode 100644 index 0000000000..da4b0fb3d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt @@ -0,0 +1,286 @@ +/* + * 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.music + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.recalculateWindowInsets +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.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.navigation.topbars.TopBarWithBackButton +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 +import com.vitorpamplona.amethyst.ui.theme.grayText + +/** + * Modal-style picker that lets a user toggle the visible track in/out of any of their own + * playlists. Replaces the earlier bookmark-style management UI (ListItem + leading icon stack + + * trailing button); music doesn't carry the public/private dimension that bookmarks do, so + * those affordances were just visual noise. + * + * Layout: + * - inline "create new playlist" row at the top (name field + Create button) + * - LazyColumn of compact rows: check icon + title + track count + * - tap-anywhere-on-row toggles membership + * - bottom link: "Manage all playlists" → opens the dedicated playlists feed + */ +@Composable +fun AddToMusicPlaylistSheet( + trackAddress: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: AddToMusicPlaylistViewModel = viewModel() + vm.init(accountViewModel, trackAddress) + + Scaffold( + modifier = Modifier.fillMaxSize().recalculateWindowInsets(), + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.add_to_music_playlist_title), + nav = nav, + ) + }, + ) { contentPadding -> + Column( + modifier = + Modifier + .padding( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ).consumeWindowInsets(contentPadding) + .imePadding(), + ) { + CreatePlaylistRow(vm = vm, accountViewModel = accountViewModel) + HorizontalDivider() + PlaylistPickerBody(vm = vm, accountViewModel = accountViewModel) + HorizontalDivider() + ManageAllPlaylistsLink(nav = nav) + } + } +} + +@Composable +private fun CreatePlaylistRow( + vm: AddToMusicPlaylistViewModel, + accountViewModel: AccountViewModel, +) { + var name by rememberSaveable { mutableStateOf("") } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + val trimmed = name.trim() + if (trimmed.isNotBlank()) { + accountViewModel.launchSigner { + if (vm.createWithTrack(trimmed) != null) name = "" + } + } + }, + enabled = name.trim().isNotBlank() && !vm.isWorking.value, + ) { + Text(stringRes(R.string.music_playlist_create_action)) + } + } +} + +@Composable +private fun ColumnScope.PlaylistPickerBody( + vm: AddToMusicPlaylistViewModel, + accountViewModel: AccountViewModel, +) { + val playlists by vm.ownedPlaylists + + if (playlists.isEmpty()) { + Text( + text = stringRes(R.string.add_to_music_playlist_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + return + } + + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + ) { + itemsIndexed( + items = playlists, + key = { _, item -> item.address.toValue() }, + ) { _, summary -> + PlaylistPickerRow( + summary = summary, + onToggle = { + accountViewModel.launchSigner { + vm.toggle(summary.address) + } + }, + ) + } + } +} + +@Composable +private fun PlaylistPickerRow( + summary: OwnedPlaylistSummary, + onToggle: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Check / plus icon swaps based on membership — the only state cue we need here, since + // playlists don't have the public/private split that justified the bookmark UI. + Icon( + symbol = + if (summary.containsTrack) { + MaterialSymbols.CheckCircle + } else { + MaterialSymbols.AutoMirrored.PlaylistAdd + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = summary.title.ifBlank { stringRes(R.string.music_playlist_untitled) }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (summary.containsTrack) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val count = summary.trackCount + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count_short, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } +} + +@Composable +private fun ManageAllPlaylistsLink(nav: INav) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(CircleShape) + .clickable { nav.nav(Route.MusicPlaylists) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.AutoMirrored.FormatListBulleted, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringRes(R.string.add_to_music_playlist_manage_all), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Preview +@Composable +private fun AddToMusicPlaylistSheetEmptyPreview() { + ThemeComparisonColumn { + AddToMusicPlaylistSheet( + // No matching addressable in LocalCache → the VM resolves zero playlists, + // which is the empty-state path we want to capture. + trackAddress = "36787:0000000000000000000000000000000000000000000000000000000000000000:preview-empty", + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt new file mode 100644 index 0000000000..56e59c17a8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt @@ -0,0 +1,197 @@ +/* + * 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.music + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** Lightweight projection for the picker rows. */ +@Immutable +data class OwnedPlaylistSummary( + val address: Address, + val title: String, + val trackCount: Int, + val containsTrack: Boolean, +) + +/** + * Loads the current user's MusicPlaylistEvents (kind 34139) and exposes add/remove operations + * that re-sign and re-broadcast the addressable with the same `d` tag — clients keying off the + * address see the new ordered track list automatically. + */ +class AddToMusicPlaylistViewModel : ViewModel() { + private lateinit var account: Account + private var trackAddress: Address? = null + + val ownedPlaylists = mutableStateOf>(emptyList()) + val isWorking = mutableStateOf(false) + + private var liveScanJob: Job? = null + + /** + * Serializes concurrent toggle/create calls. `isWorking` alone is a UI gate, not a + * concurrency primitive — quick double taps on different rows would otherwise both pass + * the early-return check, both read the same `existing` event, and the loser's broadcast + * would replace the winner's (last-write-wins by `createdAt`). + */ + private val mutationLock = Mutex() + + fun init( + accountViewModel: AccountViewModel, + trackAddressTag: String, + ) { + // Always refresh the parsed track address. The previous guard (`if account already + // set, return`) silently kept the FIRST track this VM ever saw — any later call with + // a different track would route toggle() at the stale address, adding/removing the + // wrong track from playlists. ViewModels survive process recreation and can also be + // reused by navigation in less common shapes, so the input has to drive the state. + val parsed = AddressSerializer.parse(trackAddressTag) + if (parsed == trackAddress && ::account.isInitialized && account == accountViewModel.account) { + // Same inputs — nothing to do. The live-scan job stays running. + return + } + this.account = accountViewModel.account + this.trackAddress = parsed + + // (Re)start the scan against the new track so each playlist's `containsTrack` flag + // reflects the *current* trackAddress instead of the previous one. + liveScanJob?.cancel() + liveScanJob = + viewModelScope.launch(Dispatchers.IO) { + rescan() + LocalCache.live.newEventBundles.collect { bundle -> + if (bundle.any { it.event?.kind == MusicPlaylistEvent.KIND }) { + rescan() + } + } + } + } + + override fun onCleared() { + liveScanJob?.cancel() + super.onCleared() + } + + private suspend fun rescan() { + val mePubKey = account.userProfile().pubkeyHex + val target = trackAddress + val results = + LocalCache.addressables + .filterIntoSet(MusicPlaylistEvent.KIND, mePubKey) { _, note -> + note.event is MusicPlaylistEvent + }.mapNotNull { note -> + val e = note.event as? MusicPlaylistEvent ?: return@mapNotNull null + val tracks = e.trackAddresses() + OwnedPlaylistSummary( + address = e.address(), + title = e.title().orEmpty(), + trackCount = tracks.size, + containsTrack = target != null && tracks.any { it == target }, + ) + }.sortedByDescending { it.containsTrack } + // Hop back to the main thread to publish the new list — Compose State writes go + // through Snapshot.apply, and doing them off-main is the anti-pattern flagged in + // prior reviews of this codebase even though Snapshot makes it not-yet-broken. + withContext(Dispatchers.Main.immediate) { + ownedPlaylists.value = results + } + } + + /** + * Toggle membership: if the playlist already contains the track, drop it; otherwise append + * to the end. Uses [MusicPlaylistEvent.addTrack] / [MusicPlaylistEvent.removeTrack] so every + * non-`a` tag the original event carried (custom `t` tags, description, image, etc) is + * preserved. Returns false if no track was supplied or the playlist isn't in cache. + */ + suspend fun toggle(playlistAddress: Address): Boolean { + val targetTrack = trackAddress ?: return false + return mutationLock.withLock { + if (isWorking.value) return@withLock false + setWorking(true) + try { + val existing = LocalCache.addressables.get(playlistAddress)?.event as? MusicPlaylistEvent ?: return@withLock false + + val template = + if (existing.trackAddresses().any { it == targetTrack }) { + MusicPlaylistEvent.removeTrack(existing, targetTrack) + } else { + MusicPlaylistEvent.addTrack(existing, targetTrack) + } + + account.signAndComputeBroadcast(template) + rescan() + true + } finally { + setWorking(false) + } + } + } + + /** Create a brand-new playlist containing this track. Returns the new playlist's address. */ + suspend fun createWithTrack(title: String): Address? { + val targetTrack = trackAddress ?: return null + if (title.isBlank()) return null + return mutationLock.withLock { + if (isWorking.value) return@withLock null + setWorking(true) + try { + val event = + account.signAndComputeBroadcast( + MusicPlaylistEvent.build( + title = title.trim(), + tracks = listOf(targetTrack), + ), + ) + rescan() + event.address() + } finally { + setWorking(false) + } + } + } + + /** + * Publish the [isWorking] flag on the main thread. See the comment in [rescan]: this + * coroutine is launched on [Dispatchers.IO] (via `launchSigner`), and Compose State + * writes belong on the main dispatcher even when Snapshot would technically tolerate + * them off-main. + */ + private suspend fun setWorking(working: Boolean) { + withContext(Dispatchers.Main.immediate) { + isWorking.value = working + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt new file mode 100644 index 0000000000..19dd062a3f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt @@ -0,0 +1,75 @@ +/* + * 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.music + +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 MusicPlaylistsFeedLoaded( + 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 dispatches MusicPlaylistEvent → RenderMusicPlaylist, so each row + // gets the same cover / track-row presentation the thread view uses. + 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/music/MusicPlaylistsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt new file mode 100644 index 0000000000..399faca3ce --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.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.music + +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.bottombars.FabBottomBarPadded +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.music.datasource.MusicPlaylistsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun MusicPlaylistsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + MusicPlaylistsScreen( + playlistsFeedContentState = accountViewModel.feedStates.musicPlaylistsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun MusicPlaylistsScreen( + playlistsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(playlistsFeedContentState) + WatchAccountForMusicPlaylistsScreen( + playlistsFeedState = playlistsFeedContentState, + accountViewModel = accountViewModel, + ) + // Dedicated subscription keyed on the *playlists* follow list (the top-bar spinner + // writes to a different setting than the tracks screen) and asking for only kind + // 34139, so this screen's REQ is isolated from the tracks screen's REQ. Each + // track referenced by a playlist is pulled separately by its own PlaylistTrackRow + // via observeNoteEvent. + MusicPlaylistsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + MusicPlaylistsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.MusicPlaylists, nav, accountViewModel) { route -> + if (route == Route.MusicPlaylists) { + playlistsFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + floatingButton = { + FabBottomBarPadded(nav) { + NewMusicPlaylistFab(accountViewModel) + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(playlistsFeedContentState, true) { + SaveableFeedContentState(playlistsFeedContentState, scrollStateKey = ScrollStateKeys.MUSIC_PLAYLISTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = playlistsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "MusicPlaylistsFeed", + onLoaded = { loaded -> + MusicPlaylistsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForMusicPlaylistsScreen( + playlistsFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveMusicPlaylistsFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + playlistsFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun MusicPlaylistsScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + MusicPlaylistsScreen( + playlistsFeedContentState = accountViewModel.feedStates.musicPlaylistsFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsTopBar.kt new file mode 100644 index 0000000000..faaa8c1020 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsTopBar.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.music + +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 MusicPlaylistsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultMusicPlaylistsFollowList + .collectAsStateWithLifecycle() + + MusicPlaylistsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultMusicPlaylistsFollowList, + ) + } +} + +@Composable +private fun MusicPlaylistsTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + // Same route catalog as MusicTracks (and Articles / Longs): All Follows, Your Follows, + // kind3 Follows, Around Me, Global, custom 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 MusicPlaylistsTopBarPreview() { + ThemeComparisonColumn { + MusicPlaylistsTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt new file mode 100644 index 0000000000..dad2940f76 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt @@ -0,0 +1,77 @@ +/* + * 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.music + +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 MusicTracksFeedLoaded( + 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 -> + // RenderMusicTrack already gives a full-bleed playable card; NoteCompose adds the + // reactions row + author chrome around it so taps to zap/reply/etc. work the same + // way they do 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/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt new file mode 100644 index 0000000000..3420b802e7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -0,0 +1,143 @@ +/* + * 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.music + +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.bottombars.FabBottomBarPadded +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.music.datasource.MusicTracksFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun MusicTracksScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + MusicTracksScreen( + musicTracksFeedContentState = accountViewModel.feedStates.musicTracksFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun MusicTracksScreen( + musicTracksFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(musicTracksFeedContentState) + WatchAccountForMusicTracksScreen( + musicFeedState = musicTracksFeedContentState, + accountViewModel = accountViewModel, + ) + // Drives the relay subscription that fetches kind 36787 / 34139 — without this the music + // feed only shows tracks already cached via some other path (a direct nostr: link, a + // hashtag visit, the user's own publish), so a fresh user lands on an empty feed even on + // Global. + MusicTracksFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + MusicTracksTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.MusicTracks, nav, accountViewModel) { route -> + if (route == Route.MusicTracks) { + musicTracksFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + floatingButton = { + FabBottomBarPadded(nav) { + NewMusicTrackButton(accountViewModel, nav) + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(musicTracksFeedContentState, true) { + SaveableFeedContentState(musicTracksFeedContentState, scrollStateKey = ScrollStateKeys.MUSIC_TRACKS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = musicTracksFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "MusicTracksFeed", + onLoaded = { loaded -> + MusicTracksFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForMusicTracksScreen( + musicFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveMusicTracksFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + musicFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun MusicTracksScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + MusicTracksScreen( + musicTracksFeedContentState = accountViewModel.feedStates.musicTracksFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt new file mode 100644 index 0000000000..d1f7e79cfe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt @@ -0,0 +1,85 @@ +/* + * 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.music + +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 MusicTracksTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultMusicTracksFollowList + .collectAsStateWithLifecycle() + + MusicTracksTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultMusicTracksFollowList, + ) + } +} + +@Composable +private fun MusicTracksTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + // Music is content-style (like Articles / Long-form), so reuse the same route catalog + // as those feeds — All Follows, Your Follows, kind3 Follows, Around Me, Global, custom + // 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 MusicTracksTopBarPreview() { + ThemeComparisonColumn { + MusicTracksTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt new file mode 100644 index 0000000000..89e7ddb55b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt @@ -0,0 +1,136 @@ +/* + * 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.music + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) { + var dialogOpen by rememberSaveable { mutableStateOf(false) } + + FloatingActionButton( + onClick = { dialogOpen = true }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + symbol = MaterialSymbols.AutoMirrored.PlaylistAdd, + contentDescription = stringRes(R.string.new_music_playlist), + modifier = Size26Modifier, + tint = Color.White, + ) + } + + if (dialogOpen) { + NewEmptyPlaylistDialog( + onDismiss = { dialogOpen = false }, + onCreate = { name -> + // Empty playlist: user adds tracks later via the "Add to playlist" sheet on + // any music-track note. `name` is already trimmed by the dialog's confirm + // button, no need to .trim() again here. + accountViewModel.launchSigner { + accountViewModel.account.signAndComputeBroadcast( + MusicPlaylistEvent.build(title = name), + ) + // Compose state writes belong on the main dispatcher — launchSigner is + // Dispatchers.IO, so we hop back before flipping the dialog flag. + withContext(Dispatchers.Main.immediate) { + dialogOpen = false + } + } + }, + ) + } +} + +@Composable +private fun NewEmptyPlaylistDialog( + onDismiss: () -> Unit, + onCreate: (String) -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.music_playlist_new_dialog_title)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { onCreate(name.trim()) }, + enabled = name.trim().isNotBlank(), + ) { + Text(stringRes(R.string.music_playlist_create_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} + +@Preview +@Composable +private fun NewMusicPlaylistFabPreview() { + ThemeComparisonRow { + NewMusicPlaylistFab(accountViewModel = mockAccountViewModel()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt new file mode 100644 index 0000000000..9d05b125dd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt @@ -0,0 +1,68 @@ +/* + * 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.music + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.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.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +@Composable +fun NewMusicTrackButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { nav.nav(Route.NewMusicTrack()) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = stringRes(R.string.new_music_track), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} + +@Preview +@Composable +private fun NewMusicTrackButtonPreview() { + ThemeComparisonRow { + NewMusicTrackButton(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt new file mode 100644 index 0000000000..576e8fa19e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt @@ -0,0 +1,504 @@ +/* + * 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.music + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar +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 +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewMusicTrackScreen( + editDTag: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewMusicTrackViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(accountViewModel) { + vm.init(accountViewModel, editDTag) + } + + StrippingFailureDialog(vm.strippingFailureConfirmation) + + var wantsToPickCover by remember { mutableStateOf(false) } + if (wantsToPickCover) { + GallerySelectSingle( + onImageUri = { picked -> + wantsToPickCover = false + vm.setPickedCover( + if (picked != null) persistentListOf(picked) else persistentListOf(), + ) + }, + ) + } + + var wantsToPickAudio by remember { mutableStateOf(false) } + if (wantsToPickAudio) { + AudioFileSelect( + onAudioPicked = { picked -> + wantsToPickAudio = false + vm.setPickedAudio(context, picked) + }, + ) + } + + val isBusy = vm.isSending.value + + // The send coroutine lives on accountViewModel.viewModelScope so it survives the + // screen, but the screen still owns the pop-back. Subscribe to the one-shot + // completion flow: if the user is still here when the upload finishes, we pop back; + // if they've already left, no one is listening and nothing happens here (the + // launchSigner coroutine already wrote the cleared state + fired the toast). + LaunchedEffect(vm) { + vm.completionEvents.collect { nav.popBack() } + } + + Scaffold( + topBar = { + SendingTopBar( + titleRes = R.string.new_music_track, + onCancel = { nav.popBack() }, + isActive = { vm.isValid() && !isBusy }, + onPost = { + if (!vm.isValid() || isBusy) return@SendingTopBar + vm.saveAndPublish( + context = context, + accountViewModel = accountViewModel, + ) + }, + ) + }, + ) { pad -> + // Order matters: apply Scaffold's PaddingValues (system-bar insets + topBar height), + // then consumeWindowInsets so a later imePadding() only adds what's NOT already + // covered by `pad`. Without consumeWindowInsets, imePadding double-counts the nav + // bar inset (the IME inset includes it on Android 11+) and the bottom field is + // pushed too far up when the keyboard opens. + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Inline send banner — only shown while the upload+publish is in flight. The + // banner replaces the per-picker "Uploading…" message we used to hide behind + // the placeholder; this way both the cover and the audio remain visible in + // their tiles for the whole operation (so the user doesn't think one phase is + // done while the other is still running), and the banner gives clear + // top-of-screen feedback that something IS happening. + if (isBusy) UploadInProgressBanner() + + CoverImagePicker( + vm = vm, + accountViewModel = accountViewModel, + onPick = { wantsToPickCover = true }, + enabled = !isBusy, + ) + + AudioFilePicker( + vm = vm, + onPick = { wantsToPickAudio = true }, + enabled = !isBusy, + ) + + OutlinedTextField( + value = vm.title.value, + onValueChange = { vm.title.value = it }, + label = { Text(stringRes(R.string.music_track_title_label)) }, + placeholder = { Text(stringRes(R.string.music_track_title_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + isError = vm.title.value.isBlank(), + ) + + OutlinedTextField( + value = vm.artist.value, + onValueChange = { vm.artist.value = it }, + label = { Text(stringRes(R.string.music_track_artist_label)) }, + placeholder = { Text(stringRes(R.string.music_track_artist_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + isError = vm.artist.value.isBlank(), + ) + + // No audio-URL or cover-URL text fields: the upload tiles at the top are the + // single source of truth for both. When editing an existing track the + // ViewModel still threads the previous `image` / `url` through to + // MusicTrackEvent.edit, so we don't drop them on save when the user keeps + // the existing files. + + OutlinedTextField( + value = vm.album.value, + onValueChange = { vm.album.value = it }, + label = { Text(stringRes(R.string.music_track_album_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + ) + + OutlinedTextField( + value = vm.durationSeconds.value, + onValueChange = { input -> vm.durationSeconds.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.music_track_duration_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + OutlinedTextField( + value = vm.description.value, + onValueChange = { vm.description.value = it }, + label = { Text(stringRes(R.string.music_track_description_label)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + if (vm.isEditing) { + DeleteMusicTrackRow( + vm = vm, + onDeleted = { nav.popBack() }, + accountViewModel = accountViewModel, + ) + } + } + } +} + +@Composable +private fun CoverImagePicker( + vm: NewMusicTrackViewModel, + accountViewModel: AccountViewModel, + onPick: () -> Unit, + enabled: Boolean, +) { + val picked = vm.coverMedia.value + if (picked != null) { + // Tap the preview to swap to a different image — same gesture as the Badge composer. + // While the upload is in flight (enabled = false) we drop the tap handler so the + // user can't trigger a re-pick mid-upload; the picker stays visible so they can + // see what's being sent. + Box(modifier = if (enabled) Modifier.clickable(onClick = onPick) else Modifier) { + ShowImageUploadGallery( + list = picked, + onDelete = { if (enabled) vm.clearPickedCover() }, + accountViewModel = accountViewModel, + ) + } + } else { + UploadPlaceholder( + iconSymbol = MaterialSymbols.AddPhotoAlternate, + ctaRes = R.string.music_track_cover_upload_cta, + hintRes = R.string.music_track_cover_upload_hint, + onClick = onPick, + enabled = enabled, + ) + } +} + +@Composable +private fun AudioFilePicker( + vm: NewMusicTrackViewModel, + onPick: () -> Unit, + enabled: Boolean, +) { + val pickedName = vm.pickedAudioName.value + if (pickedName != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).let { if (enabled) it.clickable(onClick = onPick) else it } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Spacer(modifier = Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = pickedName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringRes(R.string.music_track_audio_picked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (enabled) { + TextButton(onClick = { vm.clearPickedAudio() }) { + Text(stringRes(R.string.cancel)) + } + } + } + } else { + UploadPlaceholder( + iconSymbol = MaterialSymbols.MusicNote, + ctaRes = R.string.music_track_audio_upload_cta, + hintRes = R.string.music_track_audio_upload_hint, + onClick = onPick, + aspectRatio = null, + enabled = enabled, + ) + } +} + +/** + * Banner shown at the top of the form while the send coroutine is in flight. The + * coroutine runs on `accountViewModel.viewModelScope` so it survives the screen, but + * this banner is the user's primary feedback that something IS happening — without it + * the previous flow looked like a no-op after pressing Send while the cover finished + * uploading and reverted to the picker placeholder mid-flight. + */ +@Composable +private fun UploadInProgressBanner() { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = stringRes(R.string.music_track_uploading_banner), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + +/** + * Shared upload-placeholder card used by both the cover and the audio pickers. The cover + * uses a 1:1 aspect ratio (square upload tile, matching the Badge composer); the audio + * picker leaves [aspectRatio] null so the row hugs the icon + text content. + */ +@Composable +private fun UploadPlaceholder( + iconSymbol: MaterialSymbol, + ctaRes: Int, + hintRes: Int, + onClick: () -> Unit, + aspectRatio: Float? = 1f, + enabled: Boolean = true, +) { + val baseModifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).let { if (enabled) it.clickable(onClick = onClick) else it } + .padding(24.dp) + + val finalModifier = + if (aspectRatio != null) baseModifier.aspectRatio(aspectRatio) else baseModifier + + Box( + modifier = finalModifier, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + symbol = iconSymbol, + contentDescription = null, + modifier = Modifier.size(if (aspectRatio != null) 56.dp else 36.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringRes(ctaRes), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(hintRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +// Single-file audio picker using OpenDocument restricted to audio MIME types. We don't +// reuse the shared FileSelect because that one also accepts application/pdf, which would +// let the user pick a PDF and try to publish it as a track — not what this screen is for. +@Composable +private fun AudioFileSelect(onAudioPicked: (SelectedMedia?) -> Unit) { + val resolver = LocalContext.current.contentResolver + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> + onAudioPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) + }, + ) + LaunchedEffect(Unit) { + launcher.launch(arrayOf("audio/*")) + } +} + +@Composable +private fun DeleteMusicTrackRow( + vm: NewMusicTrackViewModel, + onDeleted: () -> Unit, + accountViewModel: AccountViewModel, +) { + var confirming by rememberSaveable { mutableStateOf(false) } + + OutlinedButton( + onClick = { confirming = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(text = stringRes(R.string.music_track_delete)) + } + + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringRes(R.string.music_track_delete)) }, + text = { Text(stringRes(R.string.music_track_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + confirming = false + accountViewModel.launchSigner { + if (vm.deleteLoaded()) onDeleted() + } + }) { + Text( + text = stringRes(R.string.music_track_delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { confirming = false }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +@Preview +@Composable +private fun NewMusicTrackScreenPreview() { + ThemeComparisonColumn { + NewMusicTrackScreen( + editDTag = null, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt new file mode 100644 index 0000000000..1fea16f28b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt @@ -0,0 +1,471 @@ +/* + * 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.music + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Composer for a kind-36787 Music Track. Mirrors the Badge composer flow: the user picks the + * cover image and the audio file up front (Save uploads them through Blossom/NIP-96, then + * publishes the addressable event with the returned URLs). Power users can still paste raw + * URLs into the text fields below the pickers when they're sourcing audio from elsewhere. + * + * In edit mode (`editDTag` set AND the event resolves from LocalCache), publishes via + * [MusicTrackEvent.edit] so every tag the composer doesn't surface (video, released, + * track_number, format, bitrate, sample_rate, language, explicit, extra `t` hashtags, + * zap-split tags, etc) is preserved across save. When `editDTag` is set but the event isn't in + * cache, falls back to create-mode so the user isn't trapped on a Delete button that wouldn't + * do anything. + */ +class NewMusicTrackViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val artist = mutableStateOf("") + val audioUrl = mutableStateOf("") + val coverUrl = mutableStateOf("") + val album = mutableStateOf("") + val durationSeconds = mutableStateOf("") + val description = mutableStateOf("") + + /** + * Single in-flight flag covering both the parallel uploads and the subsequent publish. + * Drives the Send button's spinner, gates double-tap, and overlays each picker with a + * progress indicator. Kept as one boolean rather than separate upload/publish flags + * because the user experience is "one Send action" — the two phases blur into each + * other and showing them as separate states only confuses the picture. + */ + val isSending = mutableStateOf(false) + + /** + * One-shot success channel. Emits exactly once when an upload+publish round succeeds; + * the screen collects it and pops back. We can't rely on a callback because the + * coroutine runs on [AccountViewModel.viewModelScope] (so the work survives screen + * dismissal), but the screen's nav handle becomes stale the moment the user leaves. + * Tying the popBack to a flow the screen subscribes to keeps it correct: if the user + * has already left, no one is listening and nothing pops; if they're still on screen, + * the dismissal fires. + */ + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + /** + * Two separate orchestrators because the cover image and the audio file go through + * different compression paths (the audio path skips image compression and the cover path + * goes through it). Sharing one orchestrator would force one path on both files. + */ + val coverMedia = mutableStateOf(null) + val audioMedia = mutableStateOf(null) + val pickedAudioName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + + val selectedServer = mutableStateOf(null) + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED — matches Badge's slider semantics. + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + /** Stable d-tag for the addressable: null = create-new, non-null = edit-existing. */ + private var dTag: String? = null + + /** The loaded event in edit mode; needed for `edit()` + NIP-09 deletion. */ + private var loadedEvent: MusicTrackEvent? = null + + /** Only `true` once an existing event has been resolved from cache. */ + val isEditing: Boolean + get() = loadedEvent != null + + fun init( + accountViewModel: AccountViewModel, + editDTag: String?, + ) { + if (::account.isInitialized) return // idempotent across recompositions + this.account = accountViewModel.account + this.selectedServer.value = account.settings.defaultFileServer + this.stripMetadata.value = account.settings.stripLocationOnUpload + + if (editDTag != null) { + val existingAddress = Address(MusicTrackEvent.KIND, account.userProfile().pubkeyHex, editDTag) + val existingNote = LocalCache.addressables.get(existingAddress) + (existingNote?.event as? MusicTrackEvent)?.let { existing -> + dTag = editDTag + loadedEvent = existing + title.value = existing.title().orEmpty() + artist.value = existing.artist().orEmpty() + audioUrl.value = existing.url().orEmpty() + coverUrl.value = existing.image().orEmpty() + album.value = existing.album().orEmpty() + durationSeconds.value = existing.duration()?.toString().orEmpty() + description.value = existing.content + } + // If the lookup fails, dTag stays null and the screen renders as create-mode. + } + } + + fun setPickedCover(uris: ImmutableList) { + coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun clearPickedCover() { + coverMedia.value = null + } + + fun setPickedAudio( + context: Context, + uri: SelectedMedia?, + ) { + if (uri == null) { + audioMedia.value = null + pickedAudioName.value = null + return + } + + audioMedia.value = MultiOrchestrator(persistentListOf(uri)) + // Fall back to the last path segment of the content:// URI — Android picker URIs + // rarely expose a clean filename, but the segment is at least stable across recomposes. + pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/') + + // Auto-fill title/artist/album/duration from the picked file's embedded metadata so + // the user doesn't have to retype what's already in the ID3 / mp4 / FLAC tags. + // MediaMetadataRetriever is heavy (opens the file), so do it on IO. We use the + // application context to avoid leaking the Activity if the picker outlives the screen. + val appContext = context.applicationContext + viewModelScope.launch(Dispatchers.IO) { + val probed = probeAudioMetadata(appContext, uri.uri) ?: return@launch + withContext(Dispatchers.Main.immediate) { + // Only fill empty fields — don't clobber whatever the user already typed. + // Duration is the exception: it's a derived number, not an opinion the user + // typed, so overwrite if the file actually reports one. + probed.duration?.let { durationSeconds.value = it.toString() } + if (title.value.isBlank()) probed.title?.let { title.value = it } + if (artist.value.isBlank()) probed.artist?.let { artist.value = it } + if (album.value.isBlank()) probed.album?.let { album.value = it } + } + } + } + + fun clearPickedAudio() { + audioMedia.value = null + pickedAudioName.value = null + } + + private data class AudioMetadata( + val duration: Int?, + val title: String?, + val artist: String?, + val album: String?, + ) + + private fun probeAudioMetadata( + context: Context, + uri: Uri, + ): AudioMetadata? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(context, uri) + val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() + AudioMetadata( + duration = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } }, + title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null }, + // MediaMetadataRetriever returns the album artist under ARTIST; ALBUMARTIST is + // distinct on multi-artist albums. Prefer ARTIST (the track-level credit), fall + // back to ALBUMARTIST if the file only has the latter. + artist = + ( + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) + ?: retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST) + )?.trim()?.ifBlank { null }, + album = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)?.trim()?.ifBlank { null }, + ) + } catch (_: Exception) { + // Some content providers reject MediaMetadataRetriever or the file isn't a real + // audio container yet (rare). Fall through to leaving the fields untouched — the + // user can fill them manually. + null + } finally { + retriever.release() + } + } + + /** + * Valid when we have a title, an artist, and a way to resolve an audio URL — either a + * picked file ready to upload, or a non-blank URL in the text field. + */ + fun isValid(): Boolean = + title.value.isNotBlank() && + artist.value.isNotBlank() && + (audioMedia.value != null || audioUrl.value.isNotBlank()) + + /** + * Upload any picked files (cover + audio) in parallel, then sign and broadcast the + * event with the resulting URLs. The whole operation runs on + * [AccountViewModel.viewModelScope] via `launchSigner`, so it keeps running even if + * the user leaves the screen — they get a toast notification when it finishes either + * way, and we don't lose half-uploaded data. + * + * Errors are reported through the global toast manager rather than a callback because + * the screen may already be gone by the time we know the outcome. + */ + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return // double-tap guard + + // Snapshot every input the upload needs into immutable locals BEFORE launching, so + // user edits to the form after pressing Send don't poison the in-flight publish + // (and so the screen-bound mutableState references survive even after the VM is + // cleared by the user navigating away). + val acc = account + val server = selectedServer.value + if (server == null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + val snapshot = + SendSnapshot( + title = title.value.trim(), + artist = artist.value.trim(), + description = description.value, + album = album.value.trim().ifBlank { null }, + duration = durationSeconds.value.trim().toIntOrNull(), + coverOrchestrator = coverMedia.value, + audioOrchestrator = audioMedia.value, + existingCoverUrl = coverUrl.value.trim().ifBlank { null }, + existingAudioUrl = audioUrl.value.trim(), + server = server, + quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value), + stripMetadata = stripMetadata.value, + loadedEvent = loadedEvent, + appContext = context.applicationContext, + ) + + isSending.value = true + + // launchSigner runs on AccountViewModel.viewModelScope, which outlives this + // per-screen VM. So if the user navigates away mid-upload, the work keeps going + // and the toast still fires on completion. + accountViewModel.launchSigner { + try { + val (newCoverUrl, newAudioUrl) = performParallelUploads(snapshot, accountViewModel) + + val finalCoverUrl = newCoverUrl ?: snapshot.existingCoverUrl + val finalAudioUrl = newAudioUrl ?: snapshot.existingAudioUrl + if (finalAudioUrl.isBlank()) { + accountViewModel.toastManager.toast( + "Audio upload failed", + "No audio URL ended up available for the published track.", + ) + return@launchSigner + } + + publishEvent(snapshot, finalCoverUrl, finalAudioUrl) + + // Remember the server + strip-metadata choice so the user doesn't have to + // re-pick them on the next track. + acc.settings.changeDefaultFileServer(snapshot.server) + acc.settings.changeStripLocationOnUpload(snapshot.stripMetadata) + + withContext(Dispatchers.Main.immediate) { + // Only NOW clear the pickers — keeping them populated through the + // whole upload+publish phase means each picker tile keeps showing the + // user the file it's working on, so they don't think the cover is + // already done while the audio is still uploading. + coverMedia.value = null + audioMedia.value = null + pickedAudioName.value = null + if (newCoverUrl != null) coverUrl.value = newCoverUrl + if (newAudioUrl != null) audioUrl.value = newAudioUrl + } + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast( + "Failed to send music track", + t.message ?: t.javaClass.simpleName, + ) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } + + /** + * Snapshot of every input the send coroutine needs. Captured before launching so the + * coroutine sees a stable view even after the VM is cleared, and so user edits to the + * form after pressing Send don't change what gets uploaded. + */ + private data class SendSnapshot( + val title: String, + val artist: String, + val description: String, + val album: String?, + val duration: Int?, + val coverOrchestrator: MultiOrchestrator?, + val audioOrchestrator: MultiOrchestrator?, + val existingCoverUrl: String?, + val existingAudioUrl: String, + val server: ServerName, + val quality: CompressorQuality, + val stripMetadata: Boolean, + val loadedEvent: MusicTrackEvent?, + val appContext: Context, + ) + + /** + * Uploads the cover and audio in parallel via `async`/`awaitAll`. Each side is + * independent: if cover fails, audio still completes (and vice versa); the first + * failure throws and propagates out through the catch in [saveAndPublish]. + * + * Returns `(coverUrl?, audioUrl?)`. A null means "no orchestrator was provided for + * that slot" (the caller falls back to the existing URL from the snapshot). + */ + private suspend fun performParallelUploads( + snapshot: SendSnapshot, + accountViewModel: AccountViewModel, + ): Pair = + coroutineScope { + val deferreds = + listOf( + async { + snapshot.coverOrchestrator?.let { runSingleUpload(it, snapshot, "cover") } + }, + async { + snapshot.audioOrchestrator?.let { runSingleUpload(it, snapshot, "audio") } + }, + ) + val results = deferreds.awaitAll() + results[0] to results[1] + } + + private suspend fun runSingleUpload( + orch: MultiOrchestrator, + snapshot: SendSnapshot, + kind: String, + ): String { + val res = + orch.upload( + alt = snapshot.title.ifBlank { null }, + contentWarningReason = null, + mediaQuality = snapshot.quality, + server = snapshot.server, + account = account, + context = snapshot.appContext, + useH265 = false, + stripMetadata = snapshot.stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + if (!res.allGood) { + throw UploadException(kind, formatUploadErrors(res.errors, snapshot.appContext)) + } + return firstUploadedUrl(res.successful) + ?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.") + } + + private class UploadException( + kind: String, + details: String, + ) : Exception("$kind upload failed: $details") + + private fun firstUploadedUrl(successful: List): String? = + successful + .firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult } + ?.url + + private fun formatUploadErrors( + errors: List, + context: Context, + ): String = + errors + .map { context.getString(it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + + private suspend fun publishEvent( + snapshot: SendSnapshot, + coverUrl: String?, + audioUrl: String, + ) { + val existing = snapshot.loadedEvent + val template = + if (existing != null) { + MusicTrackEvent.edit( + earlierVersion = existing, + title = snapshot.title, + artist = snapshot.artist, + url = audioUrl, + description = snapshot.description, + image = coverUrl, + album = snapshot.album, + duration = snapshot.duration, + ) + } else { + MusicTrackEvent.build( + title = snapshot.title, + artist = snapshot.artist, + url = audioUrl, + description = snapshot.description, + image = coverUrl, + album = snapshot.album, + duration = snapshot.duration, + ) + } + + account.signAndComputeBroadcast(template) + } + + suspend fun deleteLoaded(): Boolean { + val target = loadedEvent ?: return false + account.delete(target, emptySet()) + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt new file mode 100644 index 0000000000..9417ff2782 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt @@ -0,0 +1,89 @@ +/* + * 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.music.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.experimental.music.playlist.MusicPlaylistEvent + +/** + * Pulls every MusicPlaylistEvent (kind 34139) in the addressable cache that passes the user's + * dedicated playlists follow list and hidden/blocked rules. Mirrors [MusicTracksFeedFilter] but + * keyed on its own follow-list setting so the playlists screen can be filtered independently + * of the tracks screen. + */ +class MusicPlaylistsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "playlists-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultMusicPlaylistsFollowList.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(MusicPlaylistEvent.KIND) { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveMusicPlaylistsFollowLists.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 MusicPlaylistEvent && + 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/music/dal/MusicTracksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt new file mode 100644 index 0000000000..5c1fdffee7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt @@ -0,0 +1,92 @@ +/* + * 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.music.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.experimental.music.track.MusicTrackEvent + +/** + * Pulls every MusicTrackEvent (kind 36787) in the addressable cache that passes the user's + * dedicated music follow list and hidden/blocked rules. Backed by + * `account.settings.defaultMusicTracksFollowList` so the music screen's top-bar spinner + * stays in sync with this feed. + */ +class MusicTracksFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + // Class prefix avoids collisions with other feeds that key off the same follow list. + override fun feedKey(): String = "music-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultMusicTracksFollowList.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(MusicTrackEvent.KIND) { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveMusicTracksFollowLists.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 + // params.match() consults the follow list but not mute/spammer/word lists, so a muted + // author would leak through if we only relied on it. Mirror the LongsFeedFilter pattern. + return noteEvent is MusicTrackEvent && + 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/music/datasource/MusicPlaylistsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt new file mode 100644 index 0000000000..90a17ec475 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.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.music.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 MusicPlaylistsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class MusicPlaylistsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + MusicPlaylistsSubAssembler(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/music/datasource/MusicPlaylistsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..62010d5915 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssemblerSubscription.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.music.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 MusicPlaylistsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + MusicPlaylistsFilterAssemblerSubscription( + accountViewModel.dataSources().musicPlaylists, + accountViewModel, + ) +} + +@Composable +fun MusicPlaylistsFilterAssemblerSubscription( + dataSource: MusicPlaylistsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + MusicPlaylistsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt new file mode 100644 index 0000000000..fbd942aa97 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt @@ -0,0 +1,108 @@ +/* + * 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.music.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 + +/** + * Mirror of [MusicTracksSubAssembler] but keyed on the user's *playlists* follow list + * ([com.vitorpamplona.amethyst.model.AccountSettings.defaultMusicPlaylistsFollowList]). + * The playlists screen exposes its own top-bar spinner, and the previous setup reused the + * tracks subscription — so if the user picked, e.g., Global for playlists but Following for + * tracks, the REQ only asked Following's authors and the playlists feed showed empty. + * + * Asks for kind 34139 only — tracks referenced by playlists are loaded on demand by each + * playlist row's own per-track observer (see MusicPlaylist.kt), not bundled into this REQ. + */ +class MusicPlaylistsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: MusicPlaylistsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + // REQ now only asks for kind 34139 (playlists), keyed to this screen's follow + // list selector — no cross-feed cursor min needed. + return makeMusicPlaylistsFilter(feedSettings, since, key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: MusicPlaylistsQueryState) = key.account.userProfile() + + override fun list(key: MusicPlaylistsQueryState) = key.listName() + + fun MusicPlaylistsQueryState.listNameFlow() = account.settings.defaultMusicPlaylistsFollowList + + fun MusicPlaylistsQueryState.listName() = listNameFlow().value + + fun MusicPlaylistsQueryState.followsPerRelayFlow() = account.liveMusicPlaylistsFollowListsPerRelay + + fun MusicPlaylistsQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: MusicPlaylistsQueryState): 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.musicPlaylistsFeed.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/music/datasource/MusicTracksFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt new file mode 100644 index 0000000000..7751934347 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.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.music.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 MusicTracksQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class MusicTracksFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + MusicTracksSubAssembler(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/music/datasource/MusicTracksFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..2672179207 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssemblerSubscription.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.music.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 MusicTracksFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + MusicTracksFilterAssemblerSubscription( + accountViewModel.dataSources().musicTracks, + accountViewModel, + ) +} + +@Composable +fun MusicTracksFilterAssemblerSubscription( + dataSource: MusicTracksFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + MusicTracksQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt new file mode 100644 index 0000000000..01ea758deb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt @@ -0,0 +1,98 @@ +/* + * 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.music.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 MusicTracksSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: MusicTracksQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + // REQ now only asks for kind 36787 (tracks), so the `since` cursor lines up with + // the tracks feed alone — no cross-feed min needed. + return makeMusicTracksFilter(feedSettings, since, key.feedStates.musicTracksFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: MusicTracksQueryState) = key.account.userProfile() + + override fun list(key: MusicTracksQueryState) = key.listName() + + fun MusicTracksQueryState.listNameFlow() = account.settings.defaultMusicTracksFollowList + + fun MusicTracksQueryState.listName() = listNameFlow().value + + fun MusicTracksQueryState.followsPerRelayFlow() = account.liveMusicTracksFollowListsPerRelay + + fun MusicTracksQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: MusicTracksQueryState): 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.musicTracksFeed.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/music/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..1c075e9c58 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt @@ -0,0 +1,85 @@ +/* + * 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.music.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.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * Builds a per-relay filter set for the music *tracks* feed (kind 36787). + * Mirrors `makeLongsFilter`/`makeArticlesFilter` — every supported top-bar list selector + * (follows, authors, global, mute, hashtag, geohash, communities) maps to a dedicated + * sub-assembly. Anything not listed (e.g. future selectors we don't yet support) returns + * `emptyList` so we don't subscribe to junk. + */ +fun makeMusicTracksFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makeMusicFilter(feedSettings, MUSIC_TRACK_KINDS, since, defaultSince) + +/** + * Builds a per-relay filter set for the music *playlists* feed (kind 34139). + * Same routing as [makeMusicTracksFilter] — only the kind list differs, so each screen's + * REQ asks for just the kind it actually renders instead of one combined REQ. + */ +fun makeMusicPlaylistsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makeMusicFilter(feedSettings, MUSIC_PLAYLIST_KINDS, since, defaultSince) + +private fun makeMusicFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterMusicEventsByAllCommunities(feedSettings, kinds, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterMusicEventsByFollows(feedSettings, kinds, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterMusicEventsByAuthors(feedSettings, kinds, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterMusicEventsGlobal(feedSettings, kinds, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterMusicEventsByHashtag(feedSettings, kinds, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterMusicEventsByGeohashes(feedSettings, kinds, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterMusicEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterMusicEventsByCommunity(feedSettings, kinds, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt new file mode 100644 index 0000000000..6d59548196 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt @@ -0,0 +1,106 @@ +/* + * 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.music.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.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +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 + +// Two single-kind constants so each screen's REQ targets only the kind it actually renders. +// The tracks feed and the playlists feed live on separate top-bar list selections, so they +// also fan out as separate subscriptions — keeping them split avoids cross-kind over-fetching +// and lets `makeMusicTracksFilter` / `makeMusicPlaylistsFilter` reuse the same parameterized +// `filterMusicEventsByX` helpers below. +internal val MUSIC_TRACK_KINDS = listOf(MusicTrackEvent.KIND) +internal val MUSIC_PLAYLIST_KINDS = listOf(MusicPlaylistEvent.KIND) + +fun filterMusicEventsByAuthors( + 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 filterMusicEventsByAuthors( + 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 { + filterMusicEventsByAuthors( + relay = it.key, + kinds = kinds, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterMusicEventsByMutedAuthors( + 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 { + filterMusicEventsByAuthors( + 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/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt new file mode 100644 index 0000000000..c5bd165f40 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt @@ -0,0 +1,150 @@ +/* + * 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.music.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 + +/** + * Two REQs per relay: one asks for moderator approvals (NIP-72 kind 4550) whose `k` tag + * matches our music kinds, the other asks for the raw music events tagged with the + * community address. Mirrors the long-form / videos pattern so an unapproved track posted + * to a community still shows up. + */ +fun filterMusicEventsFromAllCommunities( + 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 filterMusicEventsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterMusicEventsFromAllCommunities( + relay = it.key, + kinds = kinds, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} + +fun filterMusicEventsFromCommunity( + 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 filterMusicEventsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterMusicEventsFromCommunity( + 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/music/datasource/subassemblies/FilterMusicEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt new file mode 100644 index 0000000000..804b88f793 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.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.music.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 filterMusicEventsByFollows( + 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 -> + filterMusicEventsByAuthors(relay, kinds, authors, sinceForRelay) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt new file mode 100644 index 0000000000..6cd1449c1f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.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.music.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 filterMusicEventsByGeohashes( + 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 filterMusicEventsByGeohashes( + 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 { + filterMusicEventsByGeohashes( + 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/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt new file mode 100644 index 0000000000..4af44740c5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.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.music.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 filterMusicEventsByHashtag( + 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 filterMusicEventsByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterMusicEventsByHashtag( + relay = relayHashSet.key, + kinds = kinds, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt new file mode 100644 index 0000000000..edd9f97d1a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt @@ -0,0 +1,53 @@ +/* + * 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.music.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 filterMusicEventsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + // No `TimeUtils.oneWeekAgo()` floor: music tracks/playlists are sparse on most relays, + // so a hard one-week-ago `since` would silently hide older content the user hasn't seen + // yet. Fall back to null (no `since`) when neither the per-relay EOSE cursor nor the + // feed-paging cursor is set — the relay's own limit + the kind filter keep the response + // bounded. + 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/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 659946bb50..18800f93dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -94,6 +96,8 @@ class NotificationFeedFilter( val ADDRESSABLE_KINDS = listOf( AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarRSVPEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index b303c50d48..683a74efb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -82,6 +84,8 @@ class UserProfileMutualFeedFilter( it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || + it.event is MusicTrackEvent || + it.event is MusicPlaylistEvent || it.event is AudioHeaderEvent || it.event is PublicMessageEvent || it.event is TorrentEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index bdaa1d8af6..d1e5ce0d4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -89,6 +91,8 @@ class UserProfileNewThreadFeedFilter( it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || + it.event is MusicTrackEvent || + it.event is MusicPlaylistEvent || it.event is AudioHeaderEvent || it.event is VoiceEvent || it.event is TorrentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index a1538e3362..69fb17f108 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -57,6 +59,8 @@ val PostsByRelayKinds2 = InteractiveStorySceneEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 0d686afd0d..310df52ed6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -122,6 +122,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent @@ -536,6 +538,8 @@ fun kindDisplayName(kind: Int): Int = AppSpecificDataEvent.KIND -> R.string.kind_user_settings AudioHeaderEvent.KIND -> R.string.kind_audio_header AudioTrackEvent.KIND -> R.string.kind_audio_track + MusicTrackEvent.KIND -> R.string.kind_music_track + MusicPlaylistEvent.KIND -> R.string.kind_music_playlist AttestationEvent.KIND -> R.string.attestation AttestationRequestEvent.KIND -> R.string.attestation_request AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 9ea627a300..a5c7ecc0b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -168,6 +168,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent @@ -228,6 +230,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease @@ -638,6 +642,10 @@ private fun FullBleedNoteCompose( AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { AudioHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) + } else if (noteEvent is MusicTrackEvent) { + RenderMusicTrack(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is MusicPlaylistEvent) { + RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ce1b6759e0..de109dde84 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -142,6 +142,7 @@ Write a message… Post Save + Send Create Rename Cancel @@ -752,6 +753,34 @@ No nest servers yet. Add one below or pick a recommended server. Videos Articles + Music + Playlists + New music track + New playlist + Title + Track title + Artist + Artist name + Audio URL + https://… .mp3 + Cover image URL (optional) + Album (optional) + Duration in seconds (optional) + Lyrics or credits (optional) + Delete this track + Publish a NIP-09 deletion for this music track? + Add to playlist + Add to playlist + You don\'t own any playlists yet. Use the field above to create one. + Manage all playlists + New playlist + Playlist name + Create + (untitled playlist) + + %1$d track + %1$d tracks + Private Bookmarks Public Bookmarks Add to Private Bookmarks @@ -1506,6 +1535,27 @@ %1$d min read + + %1$d track + %1$d tracks + + + +%1$d more track + +%1$d more tracks + + Collaborative + Private + Unknown track + Loading track… + Upload a cover image + Pick a square image to be the artwork for this track. + Upload an audio file + Pick an MP3, WAV, or FLAC. It will be uploaded to your media server on Save. + Audio file ready to upload + Uploading… + Uploading cover + audio and publishing. This will keep going even if you leave the screen. + Upload failed: %1$s + Loading location No Location Permissions @@ -2535,6 +2585,8 @@ User Settings Audio Header Audio Track + Music Track + Music Playlist Badge Awards Badge Definitions Accepted Badge Set diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt index cd232ed78f..a0056fc9dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent @@ -62,6 +64,8 @@ object SearchFilterFactory { OldBookmarkListEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, PinListEvent.KIND, ZapPollEvent.KIND, ChannelCreateEvent.KIND, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt new file mode 100644 index 0000000000..b573b72d22 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt @@ -0,0 +1,182 @@ +/* + * 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.quartz.experimental.music.playlist + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.music.playlist.tags.CollaborativeTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PrivateTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.TitleTag +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class MusicPlaylistEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + /** + * `isPrivate` wins when both flags are absent or contradictory — a published playlist with + * `private=true` should never be reported as public. Only fall back to "public by default" + * when `private` is not asserted. + */ + fun isPrivate() = tags.firstNotNullOfOrNull(PrivateTag::parse) ?: false + + /** + * Always the inverse of [isPrivate] so a playlist that tags both `public=true` AND + * `private=true` still gets reported as private — matching the doc on [isPrivate] + * ("isPrivate wins"). If we honored a standalone `public` tag here, the two methods + * would contradict each other when both tags are present. + */ + fun isPublic() = !isPrivate() + + fun isCollaborative() = tags.firstNotNullOfOrNull(CollaborativeTag::parse) ?: false + + /** + * Track references as `a` tags pointing to MusicTrackEvent (kind 36787) addressable events. + * Order is preserved as it appears in the tag array. + */ + fun trackAddresses(): List
= + tags.mapNotNull { tag -> + val address = ATag.parseAddress(tag) ?: return@mapNotNull null + if (address.kind == MusicTrackEvent.KIND) address else null + } + + override fun indexableContent(): String = + buildString { + append("title: ").append(title().orEmpty()).append('\n') + description()?.let { append("description: ").append(it).append('\n') } + append(content) + } + + companion object { + const val KIND = 34139 + const val ALT_DESCRIPTION_PREFIX = "Playlist" + const val CATEGORY_TAG = "playlist" + + @OptIn(ExperimentalUuidApi::class) + fun build( + title: String, + /** Long-form description, written into `event.content` (the JSON `content` field). */ + content: String = "", + image: String? = null, + /** Short description, written into the `description` tag. */ + description: String? = null, + tracks: List
= emptyList(), + isPrivate: Boolean = false, + isCollaborative: Boolean = false, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + dTag(dTag) + alt("$ALT_DESCRIPTION_PREFIX: $title") + + title(title) + hashtag(CATEGORY_TAG) + + image?.let { image(it) } + description?.let { description(it) } + + tracks.forEach { trackAddress(it) } + + // Always emit exactly one of public/private so clients reading either flag agree on + // visibility. Without this, an unrelated client that only checks `public` defaults + // to "true", contradicting a `private=true` on the same event. + if (isPrivate) { + private(true) + } else { + public(true) + } + if (isCollaborative) collaborative(true) + + initializer() + } + + /** + * Returns a replacement event with `trackAddress` appended to the end of the track list, + * preserving every other tag of `earlierVersion` (extra `t` genre tags, custom metadata, + * description, image, etc). If the track is already present, returns the earlier + * version's template unchanged. + */ + fun addTrack( + earlierVersion: MusicPlaylistEvent, + trackAddress: Address, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val aValue = AddressSerializer.assemble(trackAddress.kind, trackAddress.pubKeyHex, trackAddress.dTag) + val alreadyPresent = earlierVersion.tags.any { it.size > 1 && it[0] == ATag.TAG_NAME && it[1] == aValue } + val newTags = + earlierVersion.tags.builder { + if (!alreadyPresent) { + // `add` (not `addUnique`) so multiple a-tags accumulate in order. + add(arrayOf(ATag.TAG_NAME, aValue)) + } + } + return EventTemplate(createdAt, KIND, newTags, earlierVersion.content) + } + + /** + * Returns a replacement event with every `a` tag pointing at `trackAddress` removed. + * Preserves every other tag of `earlierVersion`. If the track wasn't present, the new + * tags equal the old tags. + */ + fun removeTrack( + earlierVersion: MusicPlaylistEvent, + trackAddress: Address, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val aValue = AddressSerializer.assemble(trackAddress.kind, trackAddress.pubKeyHex, trackAddress.dTag) + val newTags = + earlierVersion.tags.builder { + remove(ATag.TAG_NAME, aValue) + } + return EventTemplate(createdAt, KIND, newTags, earlierVersion.content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..409c6fce6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.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.quartz.experimental.music.playlist + +import com.vitorpamplona.quartz.experimental.music.playlist.tags.CollaborativeTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PrivateTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PublicTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.TitleTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.public(public: Boolean = true) = addUnique(PublicTag.assemble(public)) + +fun TagArrayBuilder.private(private: Boolean = true) = addUnique(PrivateTag.assemble(private)) + +fun TagArrayBuilder.collaborative(collaborative: Boolean = true) = addUnique(CollaborativeTag.assemble(collaborative)) + +fun TagArrayBuilder.trackAddress(address: Address) = add(ATag.assemble(address, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt new file mode 100644 index 0000000000..3388990ee0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt new file mode 100644 index 0000000000..7076285410 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt new file mode 100644 index 0000000000..8012720620 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt new file mode 100644 index 0000000000..3c7cf71b77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class PublicTag { + companion object { + const val TAG_NAME = "public" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(public: Boolean = true) = arrayOf(TAG_NAME, if (public) TRUE_VALUE else "false") + } +} + +class PrivateTag { + companion object { + const val TAG_NAME = "private" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(private: Boolean = true) = arrayOf(TAG_NAME, if (private) TRUE_VALUE else "false") + } +} + +class CollaborativeTag { + companion object { + const val TAG_NAME = "collaborative" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(collaborative: Boolean = true) = arrayOf(TAG_NAME, if (collaborative) TRUE_VALUE else "false") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt new file mode 100644 index 0000000000..ed8a07b3cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt @@ -0,0 +1,191 @@ +/* + * 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.quartz.experimental.music.track + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.music.track.tags.AlbumTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ArtistTag +import com.vitorpamplona.quartz.experimental.music.track.tags.BitrateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.DurationTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ExplicitTag +import com.vitorpamplona.quartz.experimental.music.track.tags.FormatTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.LanguageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ReleasedTag +import com.vitorpamplona.quartz.experimental.music.track.tags.SampleRateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TitleTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TrackNumberTag +import com.vitorpamplona.quartz.experimental.music.track.tags.UrlTag +import com.vitorpamplona.quartz.experimental.music.track.tags.VideoUrlTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class MusicTrackEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent(): String = + buildString { + append("title: ").append(title().orEmpty()).append('\n') + append("artist: ").append(artist().orEmpty()).append('\n') + album()?.let { append("album: ").append(it).append('\n') } + append(content) + } + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun artist() = tags.firstNotNullOfOrNull(ArtistTag::parse) + + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + + fun videoUrl() = tags.firstNotNullOfOrNull(VideoUrlTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun album() = tags.firstNotNullOfOrNull(AlbumTag::parse) + + fun trackNumber() = tags.firstNotNullOfOrNull(TrackNumberTag::parse) + + fun released() = tags.firstNotNullOfOrNull(ReleasedTag::parse) + + fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) + + fun format() = tags.firstNotNullOfOrNull(FormatTag::parse) + + fun bitrate() = tags.firstNotNullOfOrNull(BitrateTag::parse) + + fun sampleRate() = tags.firstNotNullOfOrNull(SampleRateTag::parse) + + fun language() = tags.firstNotNullOfOrNull(LanguageTag::parse) + + fun isExplicit() = tags.firstNotNullOfOrNull(ExplicitTag::parse) ?: false + + companion object { + const val KIND = 36787 + const val ALT_DESCRIPTION_PREFIX = "Music track" + const val GENRE_TAG = "music" + + @OptIn(ExperimentalUuidApi::class) + fun build( + title: String, + artist: String, + url: String, + description: String = "", + image: String? = null, + videoUrl: String? = null, + album: String? = null, + trackNumber: Int? = null, + released: String? = null, + duration: Int? = null, + format: String? = null, + bitrate: String? = null, + sampleRate: Int? = null, + language: String? = null, + explicit: Boolean = false, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("$ALT_DESCRIPTION_PREFIX: $title by $artist") + + title(title) + artist(artist) + url(url) + hashtag(GENRE_TAG) + + image?.let { image(it) } + videoUrl?.let { videoUrl(it) } + album?.let { album(it) } + trackNumber?.let { trackNumber(it) } + released?.let { released(it) } + duration?.let { duration(it) } + format?.let { format(it) } + bitrate?.let { bitrate(it) } + sampleRate?.let { sampleRate(it) } + language?.let { language(it) } + if (explicit) explicit(true) + + initializer() + } + + /** + * Builds a replacement event from an existing one, preserving every tag the composer + * doesn't surface (e.g. `video`, `released`, `track_number`, `format`, `bitrate`, + * `sample_rate`, `language`, `explicit`, extra `t` genre tags, `zap` splits, etc). + * + * `title`, `artist`, and `url` always replace. The composer-managed optional fields + * (`image`, `album`, `duration`) follow "null/blank = delete the tag" — the composer + * is authoritative for the fields it owns, so a user clearing the album field means + * "remove the album tag", not "leave whatever was there". The new event keeps the + * same `d` tag as `earlierVersion`, so relays treat the publish as the next version of + * the same addressable. Always re-derives `alt` from the new title+artist. + */ + fun edit( + earlierVersion: MusicTrackEvent, + title: String, + artist: String, + url: String, + description: String, + image: String?, + album: String?, + duration: Int?, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val newTags = + earlierVersion.tags.builder { + title(title) + artist(artist) + url(url) + alt("$ALT_DESCRIPTION_PREFIX: $title by $artist") + setOrRemove(image, ImageTag.TAG_NAME, ::image) + setOrRemove(album, AlbumTag.TAG_NAME, ::album) + if (duration != null) duration(duration) else remove(DurationTag.TAG_NAME) + } + return EventTemplate(createdAt, KIND, newTags, description) + } + + private fun TagArrayBuilder.setOrRemove( + value: String?, + tagName: String, + setter: (String) -> Unit, + ) { + if (value.isNullOrBlank()) remove(tagName) else setter(value) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..edcb643030 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt @@ -0,0 +1,65 @@ +/* + * 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.quartz.experimental.music.track + +import com.vitorpamplona.quartz.experimental.music.track.tags.AlbumTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ArtistTag +import com.vitorpamplona.quartz.experimental.music.track.tags.BitrateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.DurationTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ExplicitTag +import com.vitorpamplona.quartz.experimental.music.track.tags.FormatTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.LanguageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ReleasedTag +import com.vitorpamplona.quartz.experimental.music.track.tags.SampleRateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TitleTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TrackNumberTag +import com.vitorpamplona.quartz.experimental.music.track.tags.UrlTag +import com.vitorpamplona.quartz.experimental.music.track.tags.VideoUrlTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.artist(artist: String) = addUnique(ArtistTag.assemble(artist)) + +fun TagArrayBuilder.url(url: String) = addUnique(UrlTag.assemble(url)) + +fun TagArrayBuilder.videoUrl(url: String) = addUnique(VideoUrlTag.assemble(url)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.album(album: String) = addUnique(AlbumTag.assemble(album)) + +fun TagArrayBuilder.trackNumber(number: Int) = addUnique(TrackNumberTag.assemble(number)) + +fun TagArrayBuilder.released(isoDate: String) = addUnique(ReleasedTag.assemble(isoDate)) + +fun TagArrayBuilder.duration(seconds: Int) = addUnique(DurationTag.assemble(seconds)) + +fun TagArrayBuilder.format(format: String) = addUnique(FormatTag.assemble(format)) + +fun TagArrayBuilder.bitrate(bitrate: String) = addUnique(BitrateTag.assemble(bitrate)) + +fun TagArrayBuilder.sampleRate(hz: Int) = addUnique(SampleRateTag.assemble(hz)) + +fun TagArrayBuilder.language(iso639: String) = addUnique(LanguageTag.assemble(iso639)) + +fun TagArrayBuilder.explicit(explicit: Boolean = true) = addUnique(ExplicitTag.assemble(explicit)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt new file mode 100644 index 0000000000..7a4e925dcf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class AlbumTag { + companion object { + const val TAG_NAME = "album" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(album: String) = arrayOf(TAG_NAME, album) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt new file mode 100644 index 0000000000..5b576071b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ArtistTag { + companion object { + const val TAG_NAME = "artist" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(artist: String) = arrayOf(TAG_NAME, artist) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt new file mode 100644 index 0000000000..6dbc83e46f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class BitrateTag { + companion object { + const val TAG_NAME = "bitrate" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(bitrate: String) = arrayOf(TAG_NAME, bitrate) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt new file mode 100644 index 0000000000..4f0dd13aab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DurationTag { + companion object { + const val TAG_NAME = "duration" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(seconds: Int) = arrayOf(TAG_NAME, seconds.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt new file mode 100644 index 0000000000..b1332a77d0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ExplicitTag { + companion object { + const val TAG_NAME = "explicit" + const val EXPLICIT_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(EXPLICIT_VALUE, ignoreCase = true) + } + + fun assemble(explicit: Boolean = true) = arrayOf(TAG_NAME, if (explicit) EXPLICIT_VALUE else "false") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt new file mode 100644 index 0000000000..46f0c76776 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class FormatTag { + companion object { + const val TAG_NAME = "format" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(format: String) = arrayOf(TAG_NAME, format) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt new file mode 100644 index 0000000000..6f38f93755 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt new file mode 100644 index 0000000000..37ee0c2542 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class LanguageTag { + companion object { + const val TAG_NAME = "language" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(iso639: String) = arrayOf(TAG_NAME, iso639) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt new file mode 100644 index 0000000000..4c330688ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ReleasedTag { + companion object { + const val TAG_NAME = "released" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(isoDate: String) = arrayOf(TAG_NAME, isoDate) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt new file mode 100644 index 0000000000..59cbcc4597 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class SampleRateTag { + companion object { + const val TAG_NAME = "sample_rate" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(hz: Int) = arrayOf(TAG_NAME, hz.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt new file mode 100644 index 0000000000..03b77db812 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt new file mode 100644 index 0000000000..f82957b223 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TrackNumberTag { + companion object { + const val TAG_NAME = "track_number" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(trackNumber: Int) = arrayOf(TAG_NAME, trackNumber.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt new file mode 100644 index 0000000000..b3a9b68195 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class UrlTag { + companion object { + const val TAG_NAME = "url" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt new file mode 100644 index 0000000000..fdc205a38b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class VideoUrlTag { + companion object { + const val TAG_NAME = "video" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 993c414635..08ffa2caef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent @@ -463,6 +465,8 @@ class EventFactory { MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + MusicPlaylistEvent.KIND -> MusicPlaylistEvent(id, pubKey, createdAt, tags, content, sig) + MusicTrackEvent.KIND -> MusicTrackEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) KeyPackageEvent.KIND -> KeyPackageEvent(id, pubKey, createdAt, tags, content, sig) KeyPackageRelayListEvent.KIND -> KeyPackageRelayListEvent(id, pubKey, createdAt, tags, content, sig)