Merge pull request #3067 from vitorpamplona/claude/confident-allen-AOGU6

Add music tracks and playlists support with NIP-51 events
This commit is contained in:
Vitor Pamplona
2026-05-27 15:59:41 -04:00
committed by GitHub
83 changed files with 6409 additions and 42 deletions
@@ -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),
@@ -514,6 +514,12 @@ class Account(
val liveArticlesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultArticlesFollowList)
val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow
val liveMusicTracksFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultMusicTracksFollowList)
val liveMusicTracksFollowListsPerRelay = OutboxLoaderState(liveMusicTracksFollowLists, cache, scope).flow
val liveMusicPlaylistsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList)
val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow
val liveSoftwareAppsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultSoftwareAppsFollowList)
val liveSoftwareAppsFollowListsPerRelay = OutboxLoaderState(liveSoftwareAppsFollowLists, cache, scope).flow
@@ -171,6 +171,8 @@ class AccountSettings(
val defaultNestsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultMusicTracksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultMusicPlaylistsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultSoftwareAppsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = 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)
}
@@ -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)
}
@@ -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,
@@ -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,
@@ -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
}
@@ -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"
@@ -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<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
composableFromEnd<Route.MusicTracks> { MusicTracksScreen(accountViewModel, nav) }
composableFromEnd<Route.MusicPlaylists> { MusicPlaylistsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
@@ -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<NavBarItem, NavBarItemDef> =
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> =
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
NavBarItem.LIVE_STREAMS,
@@ -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()
@@ -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(
@@ -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)
}
@@ -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()
}
}
}
}
}
@@ -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)
}
@@ -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<Color>,
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<MusicPlaylistEvent>(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<Color>,
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<MusicTrackEvent>(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<MusicTrackEvent>,
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")
}
}
}
@@ -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<Color>,
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<Color>,
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")
}
}
}
@@ -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)
@@ -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)
@@ -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
) &&
@@ -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)
@@ -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,
)
@@ -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)
@@ -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,
)
@@ -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 ||
@@ -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(),
)
}
}
@@ -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<List<OwnedPlaylistSummary>>(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
}
}
}
@@ -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))
}
}
}
@@ -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(),
)
}
}
@@ -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())
}
}
@@ -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))
}
}
}
@@ -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(),
)
}
}
@@ -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())
}
}
@@ -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())
}
}
@@ -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())
}
}
@@ -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(),
)
}
}
@@ -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<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _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<MultiOrchestrator?>(null)
val audioMedia = mutableStateOf<MultiOrchestrator?>(null)
val pickedAudioName = mutableStateOf<String?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(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<SelectedMedia>) {
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<String?, String?> =
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<com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished>): String? =
successful
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
?.url
private fun formatUploadErrors(
errors: List<com.vitorpamplona.amethyst.service.uploads.UploadingState.Error>,
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
}
}
@@ -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<Note>() {
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<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it ->
accept(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveMusicPlaylistsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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<Note>() {
// 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<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it ->
accept(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveMusicTracksFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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<MusicPlaylistsQueryState>() {
val group =
listOf(
MusicPlaylistsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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)
}
@@ -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<MusicPlaylistsQueryState>,
) : PerUserAndFollowListEoseManager<MusicPlaylistsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: MusicPlaylistsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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() }
}
}
@@ -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<MusicTracksQueryState>() {
val group =
listOf(
MusicTracksSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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)
}
@@ -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<MusicTracksQueryState>,
) : PerUserAndFollowListEoseManager<MusicTracksQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: MusicTracksQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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() }
}
}
@@ -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<RelayBasedFilter> = 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<RelayBasedFilter> = makeMusicFilter(feedSettings, MUSIC_PLAYLIST_KINDS, since, defaultSince)
private fun makeMusicFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> =
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()
}
@@ -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<Int>,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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()
}
@@ -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<Int>,
communities: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
community: String,
authors: Set<String>?,
since: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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()
}
@@ -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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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()
}
}
@@ -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<Int>,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
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()
}
@@ -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<Int>,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
tags = mapOf("t" to hashtags.sorted()),
limit = 200,
since = since,
),
),
)
fun filterMusicEventsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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()
}
@@ -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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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,
),
)
}
}
@@ -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,
@@ -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
@@ -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 ||
@@ -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,
)
@@ -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
@@ -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,
+52
View File
@@ -142,6 +142,7 @@
<string name="write_a_message">Write a message…</string>
<string name="post">Post</string>
<string name="save">Save</string>
<string name="send">Send</string>
<string name="create">Create</string>
<string name="rename">Rename</string>
<string name="cancel">Cancel</string>
@@ -752,6 +753,34 @@
<string name="nests_servers_empty">No nest servers yet. Add one below or pick a recommended server.</string>
<string name="longs">Videos</string>
<string name="articles">Articles</string>
<string name="route_music_tracks">Music</string>
<string name="route_music_playlists">Playlists</string>
<string name="new_music_track">New music track</string>
<string name="new_music_playlist">New playlist</string>
<string name="music_track_title_label">Title</string>
<string name="music_track_title_placeholder">Track title</string>
<string name="music_track_artist_label">Artist</string>
<string name="music_track_artist_placeholder">Artist name</string>
<string name="music_track_audio_url_label">Audio URL</string>
<string name="music_track_audio_url_placeholder">https://… .mp3</string>
<string name="music_track_image_url_label">Cover image URL (optional)</string>
<string name="music_track_album_label">Album (optional)</string>
<string name="music_track_duration_label">Duration in seconds (optional)</string>
<string name="music_track_description_label">Lyrics or credits (optional)</string>
<string name="music_track_delete">Delete this track</string>
<string name="music_track_delete_confirm">Publish a NIP-09 deletion for this music track?</string>
<string name="add_to_music_playlist">Add to playlist</string>
<string name="add_to_music_playlist_title">Add to playlist</string>
<string name="add_to_music_playlist_empty">You don\'t own any playlists yet. Use the field above to create one.</string>
<string name="add_to_music_playlist_manage_all">Manage all playlists</string>
<string name="music_playlist_new_dialog_title">New playlist</string>
<string name="music_playlist_new_title_placeholder">Playlist name</string>
<string name="music_playlist_create_action">Create</string>
<string name="music_playlist_untitled">(untitled playlist)</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d track</item>
<item quantity="other">%1$d tracks</item>
</plurals>
<string name="private_bookmarks">Private Bookmarks</string>
<string name="public_bookmarks">Public Bookmarks</string>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
@@ -1506,6 +1535,27 @@
<string name="long_form_reading_minutes">%1$d min read</string>
<plurals name="music_playlist_track_count">
<item quantity="one">%1$d track</item>
<item quantity="other">%1$d tracks</item>
</plurals>
<plurals name="music_playlist_more_tracks">
<item quantity="one">+%1$d more track</item>
<item quantity="other">+%1$d more tracks</item>
</plurals>
<string name="music_playlist_collaborative">Collaborative</string>
<string name="music_playlist_private">Private</string>
<string name="music_playlist_unknown_track">Unknown track</string>
<string name="music_playlist_loading_track">Loading track…</string>
<string name="music_track_cover_upload_cta">Upload a cover image</string>
<string name="music_track_cover_upload_hint">Pick a square image to be the artwork for this track.</string>
<string name="music_track_audio_upload_cta">Upload an audio file</string>
<string name="music_track_audio_upload_hint">Pick an MP3, WAV, or FLAC. It will be uploaded to your media server on Save.</string>
<string name="music_track_audio_picked">Audio file ready to upload</string>
<string name="music_track_uploading">Uploading…</string>
<string name="music_track_uploading_banner">Uploading cover + audio and publishing. This will keep going even if you leave the screen.</string>
<string name="music_track_upload_failed">Upload failed: %1$s</string>
<string name="loading_location">Loading location</string>
<string name="lack_location_permissions">No Location Permissions</string>
@@ -2535,6 +2585,8 @@
<string name="kind_user_settings">User Settings</string>
<string name="kind_audio_header">Audio Header</string>
<string name="kind_audio_track">Audio Track</string>
<string name="kind_music_track">Music Track</string>
<string name="kind_music_playlist">Music Playlist</string>
<string name="kind_badge_awards">Badge Awards</string>
<string name="kind_badge_definitions">Badge Definitions</string>
<string name="kind_accepted_badge_set">Accepted Badge Set</string>
@@ -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,
@@ -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<Array<String>>,
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<Address> =
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<Address> = emptyList(),
isPrivate: Boolean = false,
isCollaborative: Boolean = false,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<MusicPlaylistEvent>.() -> 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<MusicPlaylistEvent> {
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<MusicPlaylistEvent> {
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<MusicPlaylistEvent> {
val aValue = AddressSerializer.assemble(trackAddress.kind, trackAddress.pubKeyHex, trackAddress.dTag)
val newTags =
earlierVersion.tags.builder<MusicPlaylistEvent> {
remove(ATag.TAG_NAME, aValue)
}
return EventTemplate(createdAt, KIND, newTags, earlierVersion.content)
}
}
}
@@ -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<MusicPlaylistEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<MusicPlaylistEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<MusicPlaylistEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<MusicPlaylistEvent>.public(public: Boolean = true) = addUnique(PublicTag.assemble(public))
fun TagArrayBuilder<MusicPlaylistEvent>.private(private: Boolean = true) = addUnique(PrivateTag.assemble(private))
fun TagArrayBuilder<MusicPlaylistEvent>.collaborative(collaborative: Boolean = true) = addUnique(CollaborativeTag.assemble(collaborative))
fun TagArrayBuilder<MusicPlaylistEvent>.trackAddress(address: Address) = add(ATag.assemble(address, null))
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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<String>): 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<String>): 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<String>): 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")
}
}
@@ -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<Array<String>>,
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<MusicTrackEvent>.() -> 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<MusicTrackEvent> {
val newTags =
earlierVersion.tags.builder<MusicTrackEvent> {
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<MusicTrackEvent>.setOrRemove(
value: String?,
tagName: String,
setter: (String) -> Unit,
) {
if (value.isNullOrBlank()) remove(tagName) else setter(value)
}
}
}
@@ -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<MusicTrackEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<MusicTrackEvent>.artist(artist: String) = addUnique(ArtistTag.assemble(artist))
fun TagArrayBuilder<MusicTrackEvent>.url(url: String) = addUnique(UrlTag.assemble(url))
fun TagArrayBuilder<MusicTrackEvent>.videoUrl(url: String) = addUnique(VideoUrlTag.assemble(url))
fun TagArrayBuilder<MusicTrackEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<MusicTrackEvent>.album(album: String) = addUnique(AlbumTag.assemble(album))
fun TagArrayBuilder<MusicTrackEvent>.trackNumber(number: Int) = addUnique(TrackNumberTag.assemble(number))
fun TagArrayBuilder<MusicTrackEvent>.released(isoDate: String) = addUnique(ReleasedTag.assemble(isoDate))
fun TagArrayBuilder<MusicTrackEvent>.duration(seconds: Int) = addUnique(DurationTag.assemble(seconds))
fun TagArrayBuilder<MusicTrackEvent>.format(format: String) = addUnique(FormatTag.assemble(format))
fun TagArrayBuilder<MusicTrackEvent>.bitrate(bitrate: String) = addUnique(BitrateTag.assemble(bitrate))
fun TagArrayBuilder<MusicTrackEvent>.sampleRate(hz: Int) = addUnique(SampleRateTag.assemble(hz))
fun TagArrayBuilder<MusicTrackEvent>.language(iso639: String) = addUnique(LanguageTag.assemble(iso639))
fun TagArrayBuilder<MusicTrackEvent>.explicit(explicit: Boolean = true) = addUnique(ExplicitTag.assemble(explicit))
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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<String>): 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())
}
}
@@ -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<String>): 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")
}
}
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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<String>): 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())
}
}
@@ -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>): 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)
}
}
@@ -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<String>): 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())
}
}
@@ -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>): 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)
}
}
@@ -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>): 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)
}
}
@@ -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)