mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+4
@@ -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) }
|
||||
|
||||
|
||||
+18
@@ -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()
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
|
||||
+61
-40
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+6
@@ -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
|
||||
) &&
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
+6
@@ -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 ||
|
||||
|
||||
+286
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+197
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+84
@@ -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())
|
||||
}
|
||||
}
|
||||
+77
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+85
@@ -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())
|
||||
}
|
||||
}
|
||||
+136
@@ -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())
|
||||
}
|
||||
}
|
||||
+68
@@ -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())
|
||||
}
|
||||
}
|
||||
+504
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+471
@@ -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
|
||||
}
|
||||
}
|
||||
+89
@@ -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)
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.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)
|
||||
}
|
||||
+108
@@ -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() }
|
||||
}
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.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)
|
||||
}
|
||||
+98
@@ -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() }
|
||||
}
|
||||
}
|
||||
+85
@@ -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()
|
||||
}
|
||||
+106
@@ -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()
|
||||
}
|
||||
+150
@@ -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()
|
||||
}
|
||||
+45
@@ -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()
|
||||
}
|
||||
}
|
||||
+72
@@ -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()
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.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()
|
||||
}
|
||||
+53
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
@@ -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,
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+4
@@ -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 ||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+182
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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))
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+72
@@ -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")
|
||||
}
|
||||
}
|
||||
+191
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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))
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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())
|
||||
}
|
||||
}
|
||||
+40
@@ -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")
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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())
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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())
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
|
||||
Reference in New Issue
Block a user