From 973c2eeff7abc30a1f51837c39b645d51ae22c9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:30:34 +0000 Subject: [PATCH 01/27] feat(music): add Music Track (kind 36787) and Music Playlist (kind 34139) Adds quartz support for two new addressable Nostr event kinds, modeled after the NIP-88 poll structure, plus modern Compose renderers wired into both the feed (NoteCompose) and the thread/master view (ThreadFeedView). Quartz: - MusicTrackEvent (36787): title/artist/url and optional album, track_number, released, duration, format, bitrate, sample_rate, language, explicit, image, video. Each field is a dedicated *Tag class with parse/assemble, plus a TagArrayBuilder DSL and a typed build() factory. Auto-emits the "t music" hashtag and an NIP-31 alt description. - MusicPlaylistEvent (34139): title, image, description, ordered "a" track references to MusicTrackEvent, plus public/private/collaborative flags. - Both registered in EventFactory so LocalCache materializes them as typed events instead of generic Event. Amethyst UI: - MusicTrack.kt: square cover with overlaid play affordance, large title, artist row with note icon, meta row (album/track #/release/duration/explicit badge), embedded VideoView for audio (or video URL when present) with cover thumbnail, lyric/credit content via TranslatableRichTextViewer, and topic chips for extra t tags. - MusicPlaylist.kt: cover with track-count badge, title, count + collaborative /private chips, descriptions, and an ordered list of tracks resolved via LoadAddressableNote (clickable, falls back to "loading"/"unknown track" for missing references). Capped at 25 with a "+N more tracks" footer. - Track-count strings use for correct CLDR pluralization. - Wired into NoteCompose `when` dispatch and ThreadFeedView header dispatch. --- .../amethyst/ui/note/NoteCompose.kt | 12 + .../amethyst/ui/note/types/MusicPlaylist.kt | 453 ++++++++++++++++++ .../amethyst/ui/note/types/MusicTrack.kt | 373 ++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 8 + amethyst/src/main/res/values/strings.xml | 13 + .../music/playlist/MusicPlaylistEvent.kt | 116 +++++ .../music/playlist/TagArrayBuilderExt.kt | 45 ++ .../music/playlist/tags/DescriptionTag.kt | 39 ++ .../music/playlist/tags/ImageTag.kt | 39 ++ .../music/playlist/tags/TitleTag.kt | 39 ++ .../music/playlist/tags/VisibilityTag.kt | 72 +++ .../music/track/MusicTrackEvent.kt | 135 ++++++ .../music/track/TagArrayBuilderExt.kt | 65 +++ .../experimental/music/track/tags/AlbumTag.kt | 39 ++ .../music/track/tags/ArtistTag.kt | 39 ++ .../music/track/tags/BitrateTag.kt | 39 ++ .../music/track/tags/DurationTag.kt | 39 ++ .../music/track/tags/ExplicitTag.kt | 40 ++ .../music/track/tags/FormatTag.kt | 39 ++ .../experimental/music/track/tags/ImageTag.kt | 39 ++ .../music/track/tags/LanguageTag.kt | 39 ++ .../music/track/tags/ReleasedTag.kt | 39 ++ .../music/track/tags/SampleRateTag.kt | 39 ++ .../experimental/music/track/tags/TitleTag.kt | 39 ++ .../music/track/tags/TrackNumberTag.kt | 39 ++ .../experimental/music/track/tags/UrlTag.kt | 39 ++ .../music/track/tags/VideoUrlTag.kt | 39 ++ .../quartz/utils/EventFactory.kt | 4 + 28 files changed, 1960 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 8ee29194a5..a7da9decbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -148,6 +148,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent @@ -215,6 +217,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease @@ -918,6 +922,14 @@ private fun RenderNoteRow( RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav) } + is MusicTrackEvent -> { + RenderMusicTrack(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is MusicPlaylistEvent -> { + RenderMusicPlaylist(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is DraftWrapEvent -> { RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt new file mode 100644 index 0000000000..ae829eddc9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -0,0 +1,453 @@ +/* + * 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.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.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.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.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.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +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.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +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 + +private val PLAYLIST_COVER_ASPECT_RATIO = 1f +private const val MAX_PREVIEW_TRACKS = 25 + +@Composable +fun RenderMusicPlaylist( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? MusicPlaylistEvent ?: return + + MusicPlaylistHeader( + noteEvent = noteEvent, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun MusicPlaylistHeader( + noteEvent: MusicPlaylistEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val shortDescription = remember(noteEvent) { noteEvent.description() } + val longDescription = remember(noteEvent) { noteEvent.content.ifBlank { null } } + 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) { + 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) + } + + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp) + .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, +) { + val trackEvent = trackNote.event as? MusicTrackEvent + 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, + ) + } +} + +private fun formatTrackDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt new file mode 100644 index 0000000000..f8c89445e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -0,0 +1,373 @@ +/* + * 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.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.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.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.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.Note +import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView +import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +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.grayText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import kotlinx.collections.immutable.toImmutableList + +private val COVER_ASPECT_RATIO = 1f + +@Composable +fun RenderMusicTrack( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? MusicTrackEvent ?: return + + MusicTrackHeader( + noteEvent = noteEvent, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MusicTrackHeader( + noteEvent: MusicTrackEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val artist = remember(noteEvent) { noteEvent.artist() } + val url = remember(noteEvent) { noteEvent.url() } + val videoUrl = remember(noteEvent) { noteEvent.videoUrl() } + val image = remember(noteEvent) { noteEvent.image() } + val album = remember(noteEvent) { noteEvent.album() } + val trackNumber = remember(noteEvent) { noteEvent.trackNumber() } + val released = remember(noteEvent) { noteEvent.released() } + val duration = remember(noteEvent) { noteEvent.duration() } + val format = remember(noteEvent) { noteEvent.format() } + val isExplicit = remember(noteEvent) { noteEvent.isExplicit() } + val description = remember(noteEvent) { noteEvent.content.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() + } + val playableUri = videoUrl ?: url + val mimeType = + remember(format, videoUrl) { + when { + videoUrl != null -> "video/${format ?: "mp4"}" + format != null -> "audio/$format" + else -> null + } + } + + Column(MaterialTheme.colorScheme.replyModifier) { + MusicTrackCover(image, note, 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 = formatDuration(it)) } + if (duration != null && isExplicit) MetaSeparator() + if (isExplicit) ExplicitBadge() + } + } + + if (playableUri != null) { + Spacer(Modifier.padding(top = 4.dp)) + Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))) { + if (image != null) { + LoadThumbAndThenVideoView( + videoUri = playableUri, + mimeType = mimeType, + title = title, + thumbUri = image, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } else { + VideoView( + videoUri = playableUri, + mimeType = mimeType, + title = title, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + } + } + + 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, + ) + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth()) { + DisplayUncitedHashtags(noteEvent, it, callbackUri, accountViewModel, nav) + } + } + } + + if (topics.isNotEmpty()) { + Spacer(Modifier.padding(top = 4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + topics.forEach { TopicChip(it) } + } + } + } + } +} + +@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) + } + + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(12.dp) + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(32.dp), + ) + } + } +} + +@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) { + Text( + text = "#$topic", + style = MaterialTheme.typography.labelSmall, + fontSize = Font10SP, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +private fun formatDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 9ea627a300..a5c7ecc0b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -168,6 +168,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent @@ -228,6 +230,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease @@ -638,6 +642,10 @@ private fun FullBleedNoteCompose( AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { AudioHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) + } else if (noteEvent is MusicTrackEvent) { + RenderMusicTrack(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is MusicPlaylistEvent) { + RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e9897200d3..fcc19c9623 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1505,6 +1505,19 @@ %1$d min read + + %1$d track + %1$d tracks + + + +%1$d more track + +%1$d more tracks + + Collaborative + Private + Unknown track + Loading track… + Loading location No Location Permissions diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt new file mode 100644 index 0000000000..5b2dbe4c18 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt @@ -0,0 +1,116 @@ +/* + * 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.PublicTag +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.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +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.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class MusicPlaylistEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun isPublic() = tags.firstNotNullOfOrNull(PublicTag::parse) ?: true + + fun isPrivate() = tags.firstNotNullOfOrNull(PrivateTag::parse) ?: false + + fun isCollaborative() = tags.firstNotNullOfOrNull(CollaborativeTag::parse) ?: false + + /** + * Track references as `a` tags pointing to MusicTrackEvent (kind 36787) addressable events. + * Order is preserved as it appears in the tag array. + */ + fun trackAddresses(): List
= + tags.mapNotNull { tag -> + val address = ATag.parseAddress(tag) ?: return@mapNotNull null + if (address.kind == MusicTrackEvent.KIND) address else null + } + + fun trackCount(): Int = trackAddresses().size + + companion object { + const val KIND = 34139 + const val ALT_DESCRIPTION_PREFIX = "Playlist" + const val CATEGORY_TAG = "playlist" + + @OptIn(ExperimentalUuidApi::class) + fun build( + title: String, + description: String = "", + image: String? = null, + shortDescription: String? = null, + tracks: List
= emptyList(), + isPrivate: Boolean = false, + isCollaborative: Boolean = false, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("$ALT_DESCRIPTION_PREFIX: $title") + + title(title) + hashtag(CATEGORY_TAG) + + image?.let { image(it) } + shortDescription?.let { description(it) } + + tracks.forEach { trackAddress(it) } + + if (isPrivate) { + private(true) + } else { + public(true) + } + if (isCollaborative) collaborative(true) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..409c6fce6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.playlist + +import com.vitorpamplona.quartz.experimental.music.playlist.tags.CollaborativeTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PrivateTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PublicTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.TitleTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.public(public: Boolean = true) = addUnique(PublicTag.assemble(public)) + +fun TagArrayBuilder.private(private: Boolean = true) = addUnique(PrivateTag.assemble(private)) + +fun TagArrayBuilder.collaborative(collaborative: Boolean = true) = addUnique(CollaborativeTag.assemble(collaborative)) + +fun TagArrayBuilder.trackAddress(address: Address) = add(ATag.assemble(address, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt new file mode 100644 index 0000000000..3388990ee0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt new file mode 100644 index 0000000000..7076285410 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt new file mode 100644 index 0000000000..8012720620 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt new file mode 100644 index 0000000000..3c7cf71b77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class PublicTag { + companion object { + const val TAG_NAME = "public" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(public: Boolean = true) = arrayOf(TAG_NAME, if (public) TRUE_VALUE else "false") + } +} + +class PrivateTag { + companion object { + const val TAG_NAME = "private" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(private: Boolean = true) = arrayOf(TAG_NAME, if (private) TRUE_VALUE else "false") + } +} + +class CollaborativeTag { + companion object { + const val TAG_NAME = "collaborative" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(collaborative: Boolean = true) = arrayOf(TAG_NAME, if (collaborative) TRUE_VALUE else "false") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt new file mode 100644 index 0000000000..e2a6d9d258 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt @@ -0,0 +1,135 @@ +/* + * 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.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.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class MusicTrackEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun artist() = tags.firstNotNullOfOrNull(ArtistTag::parse) + + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + + fun videoUrl() = tags.firstNotNullOfOrNull(VideoUrlTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun album() = tags.firstNotNullOfOrNull(AlbumTag::parse) + + fun trackNumber() = tags.firstNotNullOfOrNull(TrackNumberTag::parse) + + fun released() = tags.firstNotNullOfOrNull(ReleasedTag::parse) + + fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) + + fun format() = tags.firstNotNullOfOrNull(FormatTag::parse) + + fun bitrate() = tags.firstNotNullOfOrNull(BitrateTag::parse) + + fun sampleRate() = tags.firstNotNullOfOrNull(SampleRateTag::parse) + + fun language() = tags.firstNotNullOfOrNull(LanguageTag::parse) + + fun isExplicit() = tags.firstNotNullOfOrNull(ExplicitTag::parse) ?: false + + companion object { + const val KIND = 36787 + const val ALT_DESCRIPTION_PREFIX = "Music track" + const val GENRE_TAG = "music" + + @OptIn(ExperimentalUuidApi::class) + fun build( + title: String, + artist: String, + url: String, + description: String = "", + image: String? = null, + videoUrl: String? = null, + album: String? = null, + trackNumber: Int? = null, + released: String? = null, + duration: Int? = null, + format: String? = null, + bitrate: String? = null, + sampleRate: Int? = null, + language: String? = null, + explicit: Boolean = false, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("$ALT_DESCRIPTION_PREFIX: $title by $artist") + + title(title) + artist(artist) + url(url) + hashtag(GENRE_TAG) + + image?.let { image(it) } + videoUrl?.let { videoUrl(it) } + album?.let { album(it) } + trackNumber?.let { trackNumber(it) } + released?.let { released(it) } + duration?.let { duration(it) } + format?.let { format(it) } + bitrate?.let { bitrate(it) } + sampleRate?.let { sampleRate(it) } + language?.let { language(it) } + if (explicit) explicit(true) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..edcb643030 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track + +import com.vitorpamplona.quartz.experimental.music.track.tags.AlbumTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ArtistTag +import com.vitorpamplona.quartz.experimental.music.track.tags.BitrateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.DurationTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ExplicitTag +import com.vitorpamplona.quartz.experimental.music.track.tags.FormatTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.LanguageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ReleasedTag +import com.vitorpamplona.quartz.experimental.music.track.tags.SampleRateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TitleTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TrackNumberTag +import com.vitorpamplona.quartz.experimental.music.track.tags.UrlTag +import com.vitorpamplona.quartz.experimental.music.track.tags.VideoUrlTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.artist(artist: String) = addUnique(ArtistTag.assemble(artist)) + +fun TagArrayBuilder.url(url: String) = addUnique(UrlTag.assemble(url)) + +fun TagArrayBuilder.videoUrl(url: String) = addUnique(VideoUrlTag.assemble(url)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.album(album: String) = addUnique(AlbumTag.assemble(album)) + +fun TagArrayBuilder.trackNumber(number: Int) = addUnique(TrackNumberTag.assemble(number)) + +fun TagArrayBuilder.released(isoDate: String) = addUnique(ReleasedTag.assemble(isoDate)) + +fun TagArrayBuilder.duration(seconds: Int) = addUnique(DurationTag.assemble(seconds)) + +fun TagArrayBuilder.format(format: String) = addUnique(FormatTag.assemble(format)) + +fun TagArrayBuilder.bitrate(bitrate: String) = addUnique(BitrateTag.assemble(bitrate)) + +fun TagArrayBuilder.sampleRate(hz: Int) = addUnique(SampleRateTag.assemble(hz)) + +fun TagArrayBuilder.language(iso639: String) = addUnique(LanguageTag.assemble(iso639)) + +fun TagArrayBuilder.explicit(explicit: Boolean = true) = addUnique(ExplicitTag.assemble(explicit)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt new file mode 100644 index 0000000000..7a4e925dcf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class AlbumTag { + companion object { + const val TAG_NAME = "album" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(album: String) = arrayOf(TAG_NAME, album) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt new file mode 100644 index 0000000000..5b576071b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ArtistTag { + companion object { + const val TAG_NAME = "artist" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(artist: String) = arrayOf(TAG_NAME, artist) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt new file mode 100644 index 0000000000..6dbc83e46f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class BitrateTag { + companion object { + const val TAG_NAME = "bitrate" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(bitrate: String) = arrayOf(TAG_NAME, bitrate) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt new file mode 100644 index 0000000000..4f0dd13aab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DurationTag { + companion object { + const val TAG_NAME = "duration" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(seconds: Int) = arrayOf(TAG_NAME, seconds.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt new file mode 100644 index 0000000000..b1332a77d0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ExplicitTag { + companion object { + const val TAG_NAME = "explicit" + const val EXPLICIT_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(EXPLICIT_VALUE, ignoreCase = true) + } + + fun assemble(explicit: Boolean = true) = arrayOf(TAG_NAME, if (explicit) EXPLICIT_VALUE else "false") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt new file mode 100644 index 0000000000..46f0c76776 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class FormatTag { + companion object { + const val TAG_NAME = "format" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(format: String) = arrayOf(TAG_NAME, format) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt new file mode 100644 index 0000000000..6f38f93755 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt new file mode 100644 index 0000000000..37ee0c2542 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class LanguageTag { + companion object { + const val TAG_NAME = "language" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(iso639: String) = arrayOf(TAG_NAME, iso639) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt new file mode 100644 index 0000000000..4c330688ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ReleasedTag { + companion object { + const val TAG_NAME = "released" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(isoDate: String) = arrayOf(TAG_NAME, isoDate) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt new file mode 100644 index 0000000000..59cbcc4597 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class SampleRateTag { + companion object { + const val TAG_NAME = "sample_rate" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(hz: Int) = arrayOf(TAG_NAME, hz.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt new file mode 100644 index 0000000000..03b77db812 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt new file mode 100644 index 0000000000..f82957b223 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TrackNumberTag { + companion object { + const val TAG_NAME = "track_number" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(trackNumber: Int) = arrayOf(TAG_NAME, trackNumber.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt new file mode 100644 index 0000000000..b3a9b68195 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class UrlTag { + companion object { + const val TAG_NAME = "url" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt new file mode 100644 index 0000000000..fdc205a38b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class VideoUrlTag { + companion object { + const val TAG_NAME = "video" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 993c414635..08ffa2caef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent @@ -463,6 +465,8 @@ class EventFactory { MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + MusicPlaylistEvent.KIND -> MusicPlaylistEvent(id, pubKey, createdAt, tags, content, sig) + MusicTrackEvent.KIND -> MusicTrackEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) KeyPackageEvent.KIND -> KeyPackageEvent(id, pubKey, createdAt, tags, content, sig) KeyPackageRelayListEvent.KIND -> KeyPackageRelayListEvent(id, pubKey, createdAt, tags, content, sig) From 336105f98be8b880cdf63748e6d25926ce6aae5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:04:55 +0000 Subject: [PATCH 02/27] feat(music): wire music events into LocalCache consume dispatch MusicTrackEvent (36787) and MusicPlaylistEvent (34139) are addressable, so both are dispatched through consumeBaseReplaceable in justConsumeInnerInner. Without this, the events arrive from relays but are never stored in LocalCache and the UI renderers see nothing to render. --- .../com/vitorpamplona/amethyst/model/LocalCache.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ef482f8a89..5aa630df5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent @@ -3384,6 +3386,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) } From bbb7a1282e25d8d3d314479f6f843521a02620d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:17:53 +0000 Subject: [PATCH 03/27] feat(music): wire MusicTrack/MusicPlaylist kinds into feed filters, search, and labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass after LocalCache wiring: MusicTrackEvent (36787) and MusicPlaylistEvent (34139) were materialized and rendered, but a dozen filter sites still listed AudioTrackEvent.KIND alone, so music events silently dropped out of feeds, search, notifications, and relay labels. Mirrors the AudioTrackEvent pattern in every place AudioTrackEvent.KIND or `is AudioTrackEvent` appears: Feed display filters (acceptableEvent + ADDRESSABLE_KINDS): - HomeNewThreadFeedFilter - FollowPackFeedNewThreadFeedFilter - UserProfileNewThreadFeedFilter - UserProfileMutualFeedFilter - HashtagFeedFilter - GeoHashFeedFilter Relay subscription kind lists: - FilterPostsByGeohash (PostsByGeohashKinds) - FilterPostsByHashtags (PostsByHashtagKinds2) - FilterPostsByRelay (PostsByRelayKinds2) - SearchPostsByText (SearchPostsByTextKinds1) - Desktop SearchFilterFactory.defaultKindGroup1 Notifications: - NotificationFeedFilter.ADDRESSABLE_KINDS (NOTIFICATION_KINDS picks this up automatically) Relay information screen: - kindDisplayName → R.string.kind_music_track / kind_music_playlist (with the two new string resources) --- .../searchCommand/subassemblies/SearchPostsByText.kt | 4 ++++ .../feed/dal/FollowPackFeedNewThreadFeedFilter.kt | 6 ++++++ .../ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt | 6 +++++- .../loggedIn/geohash/datasource/FilterPostsByGeohash.kt | 4 ++++ .../ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt | 6 +++++- .../loggedIn/hashtag/datasource/FilterPostsByHashtags.kt | 4 ++++ .../ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt | 6 ++++++ .../loggedIn/notifications/dal/NotificationFeedFilter.kt | 4 ++++ .../profile/mutual/dal/UserProfileMutualFeedFilter.kt | 4 ++++ .../newthreads/dal/UserProfileNewThreadFeedFilter.kt | 4 ++++ .../screen/loggedIn/relay/datasource/FilterPostsByRelay.kt | 4 ++++ .../ui/screen/loggedIn/relays/RelayInformationScreen.kt | 4 ++++ amethyst/src/main/res/values/strings.xml | 2 ++ .../amethyst/desktop/subscriptions/SearchFilterFactory.kt | 4 ++++ 14 files changed, 60 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index ee6d0605a8..0a02216589 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent @@ -65,6 +67,8 @@ val SearchPostsByTextKinds1 = OldBookmarkListEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, PinListEvent.KIND, ZapPollEvent.KIND, ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index 3b97682e7c..49866f79ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -54,6 +56,8 @@ class FollowPackFeedNewThreadFeedFilter( val ADDRESSABLE_KINDS = listOf( AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, @@ -135,6 +139,8 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || + noteEvent is MusicTrackEvent || + noteEvent is MusicPlaylistEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 723b1a4373..f2b7319928 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -78,7 +80,9 @@ class GeoHashFeedFilter( event is ChannelMessageEvent || event is PrivateDmEvent || event is ZapPollEvent || - event is AudioHeaderEvent + event is AudioHeaderEvent || + event is MusicTrackEvent || + event is MusicPlaylistEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 4c02ec0a5e..257dba9cc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -48,6 +50,8 @@ val PostsByGeohashKinds = HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, WikiNoteEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index f5fc6b4974..daf3a81470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -82,7 +84,9 @@ class HashtagFeedFilter( event is ChannelMessageEvent || event is PrivateDmEvent || event is ZapPollEvent || - event is AudioHeaderEvent + event is AudioHeaderEvent || + event is MusicTrackEvent || + event is MusicPlaylistEvent ) && event.isTaggedHash(hashTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 3c4528c715..ca2b17014b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Commen import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -60,6 +62,8 @@ val PostsByHashtagKinds2 = InteractiveStorySceneEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 1373fca9ca..1e7f0dba26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -57,6 +59,8 @@ class HomeNewThreadFeedFilter( val ADDRESSABLE_KINDS = listOf( AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, ClassifiedsEvent.KIND, @@ -125,6 +129,8 @@ class HomeNewThreadFeedFilter( noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || + noteEvent is MusicTrackEvent || + noteEvent is MusicPlaylistEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 659946bb50..18800f93dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -94,6 +96,8 @@ class NotificationFeedFilter( val ADDRESSABLE_KINDS = listOf( AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarRSVPEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index b303c50d48..683a74efb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -82,6 +84,8 @@ class UserProfileMutualFeedFilter( it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || + it.event is MusicTrackEvent || + it.event is MusicPlaylistEvent || it.event is AudioHeaderEvent || it.event is PublicMessageEvent || it.event is TorrentEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index bdaa1d8af6..d1e5ce0d4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -89,6 +91,8 @@ class UserProfileNewThreadFeedFilter( it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || + it.event is MusicTrackEvent || + it.event is MusicPlaylistEvent || it.event is AudioHeaderEvent || it.event is VoiceEvent || it.event is TorrentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index a1538e3362..69fb17f108 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -57,6 +59,8 @@ val PostsByRelayKinds2 = InteractiveStorySceneEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 0d686afd0d..310df52ed6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -122,6 +122,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent @@ -536,6 +538,8 @@ fun kindDisplayName(kind: Int): Int = AppSpecificDataEvent.KIND -> R.string.kind_user_settings AudioHeaderEvent.KIND -> R.string.kind_audio_header AudioTrackEvent.KIND -> R.string.kind_audio_track + MusicTrackEvent.KIND -> R.string.kind_music_track + MusicPlaylistEvent.KIND -> R.string.kind_music_playlist AttestationEvent.KIND -> R.string.attestation AttestationRequestEvent.KIND -> R.string.attestation_request AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fcc19c9623..e4ce225fc4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2541,6 +2541,8 @@ User Settings Audio Header Audio Track + Music Track + Music Playlist Badge Awards Badge Definitions Accepted Badge Set diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt index cd232ed78f..a0056fc9dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent @@ -62,6 +64,8 @@ object SearchFilterFactory { OldBookmarkListEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, + MusicTrackEvent.KIND, + MusicPlaylistEvent.KIND, PinListEvent.KIND, ZapPollEvent.KIND, ChannelCreateEvent.KIND, From db9f4da39462fbe34f93576b45e0103d8453a3e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:28:15 +0000 Subject: [PATCH 04/27] =?UTF-8?q?fix(music):=20cover=20IS=20the=20player?= =?UTF-8?q?=20=E2=80=94=20tap=20actually=20starts=20playback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous layout stacked a non-functional static cover (with a decorative play-button overlay) above the real VideoView, so tapping the prominent UI element did nothing while the actual playback widget sat smaller below. Use LoadThumbAndThenVideoView / VideoView as the primary header so the album art is the player's own thumbnail and ExoPlayer handles the tap-to-play / streaming. Falls back to a plain cover only when the event has no playable URL at all (data-integrity case). --- .../amethyst/ui/note/types/MusicTrack.kt | 77 +++++++------------ 1 file changed, 28 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index f8c89445e4..56a0657fe0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -137,7 +137,34 @@ fun MusicTrackHeader( } Column(MaterialTheme.colorScheme.replyModifier) { - MusicTrackCover(image, note, accountViewModel) + if (playableUri != null) { + if (image != null) { + LoadThumbAndThenVideoView( + videoUri = playableUri, + mimeType = mimeType, + title = title, + thumbUri = image, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } else { + VideoView( + videoUri = playableUri, + mimeType = mimeType, + title = title, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + } else { + MusicTrackCover(image, note, accountViewModel) + } Column( modifier = @@ -195,36 +222,6 @@ fun MusicTrackHeader( } } - if (playableUri != null) { - Spacer(Modifier.padding(top = 4.dp)) - Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))) { - if (image != null) { - LoadThumbAndThenVideoView( - videoUri = playableUri, - mimeType = mimeType, - title = title, - thumbUri = image, - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - contentScale = ContentScale.FillWidth, - nostrUriCallback = "nostr:${note.toNEvent()}", - accountViewModel = accountViewModel, - ) - } else { - VideoView( - videoUri = playableUri, - mimeType = mimeType, - title = title, - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - contentScale = ContentScale.FillWidth, - nostrUriCallback = "nostr:${note.toNEvent()}", - accountViewModel = accountViewModel, - ) - } - } - } - description?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } @@ -291,24 +288,6 @@ private fun MusicTrackCover( } else { DefaultImageHeader(note, accountViewModel, imageModifier) } - - Box( - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(12.dp) - .size(56.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.92f)), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = MaterialSymbols.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(32.dp), - ) - } } } From 058f42b6e84be7993745a7e37498ee0788791ee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:27:20 +0000 Subject: [PATCH 05/27] feat(music): full-screen feed, composer with FAB, add-to-playlist sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds three discrete user-facing surfaces on top of the kind 36787 / 34139 quartz events: 1. Music feed screen (Route.MusicTracks) - MusicTracksFeedFilter pulls every kind-36787 addressable that passes the user's Home follow list + hidden/blocked rules. Reuses liveHomeFollowLists rather than introducing a new AccountSettings flag (deferred to a future iteration that wants its own spinner). - MusicTracksScreen mirrors LongsScreen's scaffold: bottom-nav-aware top bar, refreshable feed list, FAB. Each row renders through NoteCompose so reactions/zaps/replies behave like Home. - Discoverable via the side drawer (DrawerFeedsItems) and pinnable to the bottom bar (NavBarItem.MUSIC_TRACKS). 2. New-music-track composer (Route.NewMusicTrack) - NewMusicTrackViewModel mirrors NewCalendarCollectionViewModel: title / artist / audio URL / cover URL / album / duration / lyrics fields, dTag-preserving edit mode, NIP-09 deletion. Publishes via account.signAndComputeBroadcast(MusicTrackEvent.build(...)). - NewMusicTrackButton FAB wires the music screen entry to the composer. - MVP intentionally skips audio-file Blossom upload from inside the composer — users paste URLs they uploaded elsewhere. NewMediaModel wiring can be added later without disturbing the rest of the flow. 3. Add-to-playlist sheet (Route.AddToMusicPlaylist) - AddToMusicPlaylistViewModel scans LocalCache.addressables for the user's own kind-34139 playlists (filterIntoSet(kind, pubKey)) and offers toggle-membership + create-with-track operations. Each mutation re-signs the playlist with the existing dTag so the address points at the new ordered track list. - AddToMusicPlaylistSheet renders a checkbox list with an inline "new playlist" creator row. - DropDownMenu exposes the entry from the …-menu on any MusicTrackEvent note via the existing M3ActionRow row pattern. String resources added under values/strings.xml. Drawer + nav-bar catalog entries cover the new route. BottomBarFeedPreloaders adds a documented `Unit` arm for MUSIC_TRACKS so the exhaustive `when` stays exhaustive without forcing a relay-subscription file before there's logic to put in it. --- .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 6 + .../ui/navigation/bottombars/NavBarItem.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 12 + .../amethyst/ui/note/elements/DropDownMenu.kt | 10 + .../loggedIn/AccountFeedContentStates.kt | 4 + .../loggedIn/BottomBarFeedPreloaders.kt | 5 + .../loggedIn/music/AddToMusicPlaylistSheet.kt | 212 +++++++++++++++++ .../music/AddToMusicPlaylistViewModel.kt | 164 +++++++++++++ .../loggedIn/music/MusicTracksFeedLoaded.kt | 77 +++++++ .../loggedIn/music/MusicTracksScreen.kt | 120 ++++++++++ .../loggedIn/music/MusicTracksTopBar.kt | 48 ++++ .../loggedIn/music/NewMusicTrackButton.kt | 56 +++++ .../loggedIn/music/NewMusicTrackScreen.kt | 216 ++++++++++++++++++ .../loggedIn/music/NewMusicTrackViewModel.kt | 134 +++++++++++ .../music/dal/MusicTracksFeedFilter.kt | 86 +++++++ amethyst/src/main/res/values/strings.xml | 30 +++ 17 files changed, 1190 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index a14f3bd526..1ccd2c4496 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -75,6 +75,7 @@ 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 SEARCH_SCREEN = "SearchFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index df8de1d607..e99777d6dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -135,6 +135,9 @@ 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.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 +284,9 @@ fun BuildNavigation( composableFromEndArgs { NestLobbyScreen(it.addressValue, accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } composableFromEnd { ArticlesScreen(accountViewModel, nav) } + composableFromEnd { MusicTracksScreen(accountViewModel, nav) } + composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } + composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 735b669e1e..df6f2e93b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -54,6 +54,7 @@ enum class NavBarItem { CALENDARS, CALENDAR_COLLECTIONS, SHORTS, + MUSIC_TRACKS, PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, @@ -230,6 +231,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.PlayCircle, resolveRoute = { Route.Shorts }, ), + NavBarItem.MUSIC_TRACKS to + NavBarItemDef( + id = NavBarItem.MUSIC_TRACKS, + labelRes = R.string.route_music_tracks, + icon = MaterialSymbols.MusicNote, + resolveRoute = { Route.MusicTracks }, + ), NavBarItem.PUBLIC_CHATS to NavBarItemDef( id = NavBarItem.PUBLIC_CHATS, @@ -346,6 +354,7 @@ val DrawerFeedsItems: List = NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SHORTS, + NavBarItem.MUSIC_TRACKS, NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b1dbad770f..9da7b8cd73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -142,6 +142,18 @@ sealed class Route { @Serializable object Articles : Route() + @Serializable object MusicTracks : Route() + + @Serializable + data class NewMusicTrack( + val dTag: String? = null, + ) : Route() + + @Serializable + data class AddToMusicPlaylist( + val trackAddress: String, + ) : Route() + @Serializable object Chess : Route() @Serializable object Wallet : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index c5b03619e4..2c31dc238d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -285,6 +286,15 @@ fun NoteDropDownMenu( } } } + // Music tracks (kind 36787) can additionally be added to one of the user's + // own playlists (kind 34139). This is independent of bookmarking — the menu + // still falls through to the bookmark rows below. + if (note.event is MusicTrackEvent && note is AddressableNote) { + M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.add_to_music_playlist)) { + nav.nav(Route.AddToMusicPlaylist(note.address.toValue())) + onDismiss() + } + } // Emoji packs belong in the user's emoji list (kind 10030), not the bookmark list. if (note.event is EmojiPackEvent) { val emojiText = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 42555a177c..1f464dc36f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -51,6 +51,7 @@ 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.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 +113,7 @@ 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 softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) @@ -201,6 +203,7 @@ class AccountFeedContentStates( nestsFeed.updateFeedWith(newNotes) longsFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes) + musicTracksFeed.updateFeedWith(newNotes) softwareAppsFeed.updateFeedWith(newNotes) calendarAppointmentsFeed.updateFeedWith(newNotes) @@ -257,6 +260,7 @@ class AccountFeedContentStates( nestsFeed.deleteFromFeed(newNotes) longsFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes) + musicTracksFeed.deleteFromFeed(newNotes) softwareAppsFeed.deleteFromFeed(newNotes) calendarAppointmentsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 8af58972f8..63d342221d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -95,6 +95,11 @@ private fun PreloadFor( NavBarItem.SHORTS -> ShortsFilterAssemblerSubscription(accountViewModel) + // No dedicated relay subscription yet — music tracks (kind 36787) arrive through the + // existing per-author home and profile subscriptions. When music gets its own + // FilterAssembler, replace this with `MusicTracksFilterAssemblerSubscription(...)`. + NavBarItem.MUSIC_TRACKS -> Unit + NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel) NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt new file mode 100644 index 0000000000..89e34e32f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt @@ -0,0 +1,212 @@ +/* + * 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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +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.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.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.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddToMusicPlaylistSheet( + trackAddress: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: AddToMusicPlaylistViewModel = viewModel() + vm.init(accountViewModel, trackAddress) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringRes(R.string.add_to_music_playlist_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + Button( + onClick = { nav.popBack() }, + colors = ButtonDefaults.textButtonColors(), + ) { + Text(stringRes(R.string.add_to_music_playlist_done)) + } + }, + colors = TopAppBarDefaults.topAppBarColors(), + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding() + .fillMaxWidth(), + ) { + NewPlaylistRow(vm = vm, accountViewModel = accountViewModel) + + HorizontalDivider() + + 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), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(playlists, key = { it.address.toValue() }) { summary -> + PlaylistPickerRow( + summary = summary, + onToggle = { + accountViewModel.launchSigner { + vm.toggle(summary.address) + } + }, + ) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun NewPlaylistRow( + vm: AddToMusicPlaylistViewModel, + accountViewModel: AccountViewModel, +) { + var newName by rememberSaveable { mutableStateOf("") } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + ) + + Spacer(Modifier.width(12.dp)) + + Button( + onClick = { + val name = newName.trim() + if (name.isNotBlank()) { + accountViewModel.launchSigner { + if (vm.createWithTrack(name) != null) { + newName = "" + } + } + } + }, + enabled = newName.trim().isNotBlank() && !vm.isWorking.value, + ) { + Text(stringRes(R.string.add_to_music_playlist_new)) + } + } +} + +@Composable +private fun PlaylistPickerRow( + summary: OwnedPlaylistSummary, + onToggle: () -> Unit, +) { + val trackCount = summary.trackCount + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = summary.containsTrack, onCheckedChange = { onToggle() }) + Column( + modifier = Modifier.padding(start = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = summary.title.ifBlank { stringRes(R.string.add_to_music_playlist_new) }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count_short, trackCount, trackCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt new file mode 100644 index 0000000000..ef26b67a66 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt @@ -0,0 +1,164 @@ +/* + * 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 + +/** Lightweight projection for the picker rows. */ +@Immutable +data class OwnedPlaylistSummary( + val address: Address, + val title: String, + val trackCount: Int, + val containsTrack: Boolean, +) + +/** + * Loads the current user's MusicPlaylistEvents (kind 34139) and exposes add/remove operations + * that re-sign and re-broadcast the addressable with the same `d` tag — clients keying off the + * address see the new ordered track list automatically. + */ +class AddToMusicPlaylistViewModel : ViewModel() { + private lateinit var account: Account + private var trackAddress: Address? = null + + val ownedPlaylists = mutableStateOf>(emptyList()) + val isWorking = mutableStateOf(false) + + private var liveScanJob: Job? = null + + fun init( + accountViewModel: AccountViewModel, + trackAddressTag: String, + ) { + if (::account.isInitialized) return + this.account = accountViewModel.account + this.trackAddress = AddressSerializer.parse(trackAddressTag) + + rescan() + + // Re-scan when new events arrive — a playlist published from another screen, or one + // arriving fresh from a relay, should show up here without forcing the user to re-open. + liveScanJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { rescan() } + } + } + + override fun onCleared() { + liveScanJob?.cancel() + super.onCleared() + } + + private 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 } + ownedPlaylists.value = results + } + + /** + * Toggle membership: if the playlist already contains the track, drop it; otherwise append + * to the end. Either way we re-publish the playlist event with the same `dTag` so the + * addressable replaces the prior version. Returns false if no track was supplied (the route + * was opened in a malformed state). + */ + suspend fun toggle(playlistAddress: Address): Boolean { + val targetTrack = trackAddress ?: return false + if (isWorking.value) return false + isWorking.value = true + try { + val existing = LocalCache.addressables.get(playlistAddress)?.event as? MusicPlaylistEvent ?: return false + + val current = existing.trackAddresses() + val nextTracks = + if (current.any { it == targetTrack }) { + current.filterNot { it == targetTrack } + } else { + current + targetTrack + } + + account.signAndComputeBroadcast( + MusicPlaylistEvent.build( + title = existing.title().orEmpty(), + description = existing.content, + image = existing.image(), + shortDescription = existing.description(), + tracks = nextTracks, + isPrivate = existing.isPrivate(), + isCollaborative = existing.isCollaborative(), + dTag = existing.dTag(), + ), + ) + rescan() + return true + } finally { + isWorking.value = 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 + if (isWorking.value) return null + isWorking.value = true + try { + val event = + account.signAndComputeBroadcast( + MusicPlaylistEvent.build( + title = title.trim(), + tracks = listOf(targetTrack), + ), + ) + rescan() + return event.address() + } finally { + isWorking.value = false + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt new file mode 100644 index 0000000000..dad2940f76 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksFeedLoaded.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun MusicTracksFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + // RenderMusicTrack already gives a full-bleed playable card; NoteCompose adds the + // reactions row + author chrome around it so taps to zap/reply/etc. work the same + // way they do on the home feed. + NoteCompose( + baseNote = item, + modifier = Modifier, + isBoostedNote = false, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt new file mode 100644 index 0000000000..fe330bf9f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -0,0 +1,120 @@ +/* + * 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.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.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@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, + ) + + 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.liveHomeFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + musicFeedState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt new file mode 100644 index 0000000000..5270525b7f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun MusicTracksTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + // MVP doesn't expose a dedicated follow-list spinner here — the feed inherits the + // user's Home follow list (see MusicTracksFeedFilter). When music gets its own + // setting, swap this for a FeedFilterSpinner like LongsTopBar / BadgesTopBar. + Text( + text = stringRes(R.string.route_music_tracks), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt new file mode 100644 index 0000000000..558c59d61e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt @@ -0,0 +1,56 @@ +/* + * 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 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.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt new file mode 100644 index 0000000000..cc45a021e7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt @@ -0,0 +1,216 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +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.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.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.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewMusicTrackScreen( + editDTag: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewMusicTrackViewModel = viewModel() + vm.init(accountViewModel, editDTag) + + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.new_music_track, + onCancel = { nav.popBack() }, + onPost = { + accountViewModel.launchSigner { + if (vm.publish()) { + nav.popBack() + } + } + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding( + start = 16.dp, + end = 16.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + 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(), + ) + + OutlinedTextField( + value = vm.audioUrl.value, + onValueChange = { vm.audioUrl.value = it }, + label = { Text(stringRes(R.string.music_track_audio_url_label)) }, + placeholder = { Text(stringRes(R.string.music_track_audio_url_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + isError = vm.audioUrl.value.isBlank(), + ) + + OutlinedTextField( + value = vm.coverUrl.value, + onValueChange = { vm.coverUrl.value = it }, + label = { Text(stringRes(R.string.music_track_image_url_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + + 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 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)) + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt new file mode 100644 index 0000000000..a639593373 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt @@ -0,0 +1,134 @@ +/* + * 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.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.core.Address + +/** + * Composer for a kind-36787 Music Track. Keeps the form text-only for MVP — users paste audio + * and cover URLs (typically from a Blossom upload they ran separately). A future iteration + * can wire NewMediaModel + GallerySelect here so the composer uploads the audio itself. + * + * In edit mode (`editDTag` set), preserves the `d` tag so the published event replaces the + * earlier addressable instead of creating a new track. + */ +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("") + val isPublishing = mutableStateOf(false) + + /** 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 to publish a NIP-09 deletion. */ + private var loadedEvent: MusicTrackEvent? = null + + val isEditing: Boolean + get() = dTag != null + + fun init( + accountViewModel: AccountViewModel, + editDTag: String?, + ) { + if (::account.isInitialized) return // idempotent across recompositions + this.account = accountViewModel.account + dTag = editDTag + + editDTag?.let { existingDTag -> + val existingAddress = Address(MusicTrackEvent.KIND, account.userProfile().pubkeyHex, existingDTag) + val existingNote = LocalCache.addressables.get(existingAddress) + (existingNote?.event as? MusicTrackEvent)?.let { existing -> + 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 + } + } + } + + fun isValid(): Boolean = title.value.isNotBlank() && artist.value.isNotBlank() && audioUrl.value.isNotBlank() + + suspend fun publish(): Boolean { + if (!isValid()) return false + isPublishing.value = true + try { + val effectiveDTag = dTag + val parsedTitle = title.value.trim() + val parsedArtist = artist.value.trim() + val parsedUrl = audioUrl.value.trim() + val parsedCover = coverUrl.value.trim().ifBlank { null } + val parsedAlbum = album.value.trim().ifBlank { null } + val parsedDuration = durationSeconds.value.trim().toIntOrNull() + val parsedDescription = description.value + + account.signAndComputeBroadcast( + if (effectiveDTag != null) { + MusicTrackEvent.build( + title = parsedTitle, + artist = parsedArtist, + url = parsedUrl, + description = parsedDescription, + image = parsedCover, + album = parsedAlbum, + duration = parsedDuration, + dTag = effectiveDTag, + ) + } else { + MusicTrackEvent.build( + title = parsedTitle, + artist = parsedArtist, + url = parsedUrl, + description = parsedDescription, + image = parsedCover, + album = parsedAlbum, + duration = parsedDuration, + ) + }, + ) + return true + } finally { + isPublishing.value = false + } + } + + suspend fun deleteLoaded(): Boolean { + val target = loadedEvent ?: return false + account.delete(target, emptySet()) + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt new file mode 100644 index 0000000000..6aaa3649af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt @@ -0,0 +1,86 @@ +/* + * 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 + * Home follow list and hidden/blocked rules. Reuses `liveHomeFollowLists` rather than + * defining a dedicated music-feed follow list so the MVP doesn't have to touch + * AccountSettings + Account state plumbing — the trade-off is that changing the Home + * filter also changes this feed, which mirrors what users already expect from the Home + * tab itself. + */ +class MusicTracksFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultHomeFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> + val noteEvent = it.event + noteEvent is MusicTrackEvent && params.match(noteEvent, it.relays) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveHomeFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is MusicTrackEvent && params.match(noteEvent, it.relays) + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e4ce225fc4..462b31df3b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -752,6 +752,36 @@ No nest servers yet. Add one below or pick a recommended server. Videos Articles + Music + New music track + Title + Track title + Artist + Artist name + Audio URL + https://… .mp3 + Cover image URL (optional) + Album (optional) + Duration in seconds (optional) + Lyrics or credits (optional) + Publish + Delete this track + Publish a NIP-09 deletion for this music track? + Uploading audio… + Uploading cover… + Pick audio file + Pick cover image + Add to playlist + Add to playlist + You don\'t own any playlists yet. Create one to add this track. + New playlist + Done + Playlist name + Already in this playlist + + %1$d track + %1$d tracks + Private Bookmarks Public Bookmarks Add to Private Bookmarks From 22103412913f083f87b2e730a04ec67b9912b60a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:37:52 +0000 Subject: [PATCH 06/27] refactor(music): align Add-to-Playlist with bookmark-management UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compared the music-playlist-management sheet against the existing PostBookmarkListManagementScreen and noticed the playlist sheet was the odd one out — custom TopAppBar with a Done action, inline TextField + Button row for creating new lists, custom Row + Checkbox per playlist, no way to tap into a playlist to view it. The bookmark screen uses a richer Material3 pattern that's already familiar to users. Aligning the music sheet to it: - TopBarWithBackButton (back arrow + title) replaces the bespoke top bar. - Scaffold FAB → NewListButton opens a small AlertDialog with a name field (lighter than the bookmark route to a full edit screen, but consistent in affordance). The previous inline create-row is gone. - Each row is now a MusicPlaylistManagementItem mirroring BookmarkGroupManagementItem: leading icon + total-track-count chip, headline title, supporting "In this playlist" / "Not in this playlist" status text, trailing round IconButton (red Remove / blue Add). - Tapping the row navigates into the playlist's note view (Route.Note with the addressable's tag); tapping the trailing button toggles membership. Previously these were collapsed into a single whole-row tap that toggled but never let the user actually see the playlist. ViewModel unchanged — the same toggle()/createWithTrack() operations now just feed a more conventional UI. --- .../loggedIn/music/AddToMusicPlaylistSheet.kt | 240 ++++++++---------- .../music/MusicPlaylistManagementItem.kt | 178 +++++++++++++ amethyst/src/main/res/values/strings.xml | 14 +- 3 files changed, 295 insertions(+), 137 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt index 89e34e32f6..7b916d4b45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt @@ -20,49 +20,41 @@ */ 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.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.width +import androidx.compose.foundation.layout.recalculateWindowInsets import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +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.Alignment import androidx.compose.ui.Modifier -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.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded 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.lists.list.NewListButton import com.vitorpamplona.amethyst.ui.stringRes -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddToMusicPlaylistSheet( trackAddress: String, @@ -72,141 +64,123 @@ fun AddToMusicPlaylistSheet( val vm: AddToMusicPlaylistViewModel = viewModel() vm.init(accountViewModel, trackAddress) + var creating by rememberSaveable { mutableStateOf(false) } + Scaffold( + modifier = Modifier.fillMaxSize().recalculateWindowInsets(), topBar = { - TopAppBar( - title = { - Text( - text = stringRes(R.string.add_to_music_playlist_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - Button( - onClick = { nav.popBack() }, - colors = ButtonDefaults.textButtonColors(), - ) { - Text(stringRes(R.string.add_to_music_playlist_done)) - } - }, - colors = TopAppBarDefaults.topAppBarColors(), + TopBarWithBackButton( + caption = stringRes(R.string.add_to_music_playlist_title), + nav = nav, ) }, - ) { pad -> + floatingActionButton = { + FabBottomBarPadded(nav) { + NewListButton(onClick = { creating = true }) + } + }, + ) { contentPadding -> Column( modifier = Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding() - .fillMaxWidth(), + .padding( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ).consumeWindowInsets(contentPadding) + .imePadding(), ) { - NewPlaylistRow(vm = vm, accountViewModel = accountViewModel) - - HorizontalDivider() - - 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), - ) - } else { - LazyColumn(modifier = Modifier.fillMaxWidth()) { - items(playlists, key = { it.address.toValue() }) { summary -> - PlaylistPickerRow( - summary = summary, - onToggle = { - accountViewModel.launchSigner { - vm.toggle(summary.address) - } - }, - ) - HorizontalDivider() - } - } - } + AddToMusicPlaylistBody(vm = vm, accountViewModel = accountViewModel, nav = nav) } } -} -@Composable -private fun NewPlaylistRow( - vm: AddToMusicPlaylistViewModel, - accountViewModel: AccountViewModel, -) { - var newName by rememberSaveable { mutableStateOf("") } - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, - modifier = Modifier.weight(1f), - singleLine = true, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), - ) - - Spacer(Modifier.width(12.dp)) - - Button( - onClick = { - val name = newName.trim() - if (name.isNotBlank()) { - accountViewModel.launchSigner { - if (vm.createWithTrack(name) != null) { - newName = "" - } + if (creating) { + NewMusicPlaylistDialog( + onDismiss = { creating = false }, + onCreate = { name -> + accountViewModel.launchSigner { + if (vm.createWithTrack(name) != null) { + creating = false } } }, - enabled = newName.trim().isNotBlank() && !vm.isWorking.value, - ) { - Text(stringRes(R.string.add_to_music_playlist_new)) + ) + } +} + +@Composable +private fun AddToMusicPlaylistBody( + vm: AddToMusicPlaylistViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + 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(), + ) { + itemsIndexed( + items = playlists, + key = { _, item -> item.address.toValue() }, + ) { _, summary -> + MusicPlaylistManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + playlistTitle = summary.title, + isTrackInPlaylist = summary.containsTrack, + totalTracks = summary.trackCount, + onClick = { nav.nav(Route.Note(summary.address.toValue())) }, + onToggle = { + accountViewModel.launchSigner { + vm.toggle(summary.address) + } + }, + ) } } } @Composable -private fun PlaylistPickerRow( - summary: OwnedPlaylistSummary, - onToggle: () -> Unit, +private fun NewMusicPlaylistDialog( + onDismiss: () -> Unit, + onCreate: (String) -> Unit, ) { - val trackCount = summary.trackCount + var name by rememberSaveable { mutableStateOf("") } - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox(checked = summary.containsTrack, onCheckedChange = { onToggle() }) - Column( - modifier = Modifier.padding(start = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = summary.title.ifBlank { stringRes(R.string.add_to_music_playlist_new) }, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + 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(), ) - Text( - text = pluralStringResource(R.plurals.music_playlist_track_count_short, trackCount, trackCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + }, + 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)) + } + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt new file mode 100644 index 0000000000..b2e956c0e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt @@ -0,0 +1,178 @@ +/* + * 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.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +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.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +/** + * One row in the Add-to-Playlist sheet. Mirrors `BookmarkGroupManagementItem` so the visual + * vocabulary matches the bookmark UI: leading icon + total count, headline = playlist title, + * supporting = membership text, trailing = round add/remove action button. Row click navigates + * to the playlist itself; the trailing button is the toggle. + */ +@Composable +fun MusicPlaylistManagementItem( + modifier: Modifier = Modifier, + playlistTitle: String, + isTrackInPlaylist: Boolean, + totalTracks: Int, + onClick: () -> Unit, + onToggle: () -> Unit, +) { + ListItem( + modifier = modifier.clickable(onClick = onClick), + headlineContent = { + Text( + text = playlistTitle.ifBlank { stringRes(R.string.music_playlist_untitled) }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + PlaylistMembershipStatus(isTrackInPlaylist = isTrackInPlaylist) + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = stringRes(R.string.music_playlist_icon_label), + modifier = Size50Modifier, + ) + Spacer(StdVertSpacer) + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count_short, totalTracks, totalTracks), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + trailingContent = { + PlaylistToggleButton( + isTrackInPlaylist = isTrackInPlaylist, + onToggle = onToggle, + ) + }, + ) +} + +@Composable +private fun PlaylistMembershipStatus(isTrackInPlaylist: Boolean) { + Row( + modifier = HalfHalfVertPadding, + horizontalArrangement = SpacedBy5dp, + verticalAlignment = Alignment.CenterVertically, + ) { + val text = + if (isTrackInPlaylist) { + stringRes(R.string.music_playlist_presence_indicator) + } else { + stringRes(R.string.music_playlist_absence_indicator) + } + + val icon = + if (isTrackInPlaylist) { + MaterialSymbols.PlayCircle + } else { + MaterialSymbols.RemoveCircleOutline + } + + Icon( + symbol = icon, + contentDescription = text, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + } +} + +@Composable +private fun PlaylistToggleButton( + isTrackInPlaylist: Boolean, + onToggle: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconButton( + onClick = onToggle, + modifier = + Modifier + .background( + color = + if (isTrackInPlaylist) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primary + }, + shape = RoundedCornerShape(percent = 80), + ), + ) { + if (isTrackInPlaylist) { + Icon( + symbol = MaterialSymbols.RemoveCircleOutline, + contentDescription = stringRes(R.string.music_playlist_remove_action_desc), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } else { + MaterialSymbols.AutoMirrored.PlaylistAdd.let { addSymbol -> + Icon( + symbol = addSymbol, + contentDescription = stringRes(R.string.music_playlist_add_action_desc), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 462b31df3b..02e7d5b1cb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -773,11 +773,17 @@ Pick cover image Add to playlist Add to playlist - You don\'t own any playlists yet. Create one to add this track. - New playlist - Done + You don\'t own any playlists yet. Tap the button to create one. + New playlist + New playlist Playlist name - Already in this playlist + Create + Playlist + (untitled playlist) + In this playlist + Not in this playlist + Add this track to the playlist + Remove this track from the playlist %1$d track %1$d tracks From 8424140d60e65a6a20352928db4dd71a191c7fca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:51:36 +0000 Subject: [PATCH 07/27] chore(music): @Preview composables for every new UI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds previews wherever there's something visual worth previewing: - MusicTrack.kt: RenderMusicTrack (full event with cover + meta), RenderMusicTrackExplicit (explicit-badge variant), MusicTrackCover (no cover fallback), ExplicitBadge, TopicChip. Uses the same Event-constructor + LocalCache.justConsume + LoadNote pattern Attestation and Wiki previews already use. - MusicPlaylist.kt: RenderMusicPlaylist (with three resolved track refs), RenderMusicPlaylistCollaborativePrivate (chips visible), MusicPlaylistCover, MissingPlaylistTrackRow, TrackCoverPlaceholder, PlaylistTag. - MusicTracksScreen.kt: full feed scaffold via mockAccountViewModel(). - MusicTracksTopBar.kt: the static-title top bar. - NewMusicTrackButton.kt: the FAB on its own. - NewMusicTrackScreen.kt: the composer form. - AddToMusicPlaylistSheet.kt: NewMusicPlaylistDialog leaf, and the empty state of the full sheet. - MusicPlaylistManagementItem.kt: four variants — not-in / in / empty (zero tracks) / untitled. Skipped MusicTracksFeedLoaded — FeedState.Loaded can't be easily mocked and no existing feed-loaded previews exist in the codebase. The screen preview already covers the scaffold around it. URLs in the constructed events point at example.invalid so MyAsyncImage falls back to the deterministic DefaultImageHeader robohash and VideoView shows its tap-to-load thumbnail state — both of which are the real first-paint state users see before tapping anything. --- .../amethyst/ui/note/types/MusicPlaylist.kt | 209 ++++++++++++++++++ .../amethyst/ui/note/types/MusicTrack.kt | 164 ++++++++++++++ .../loggedIn/music/AddToMusicPlaylistSheet.kt | 26 +++ .../music/MusicPlaylistManagementItem.kt | 58 +++++ .../loggedIn/music/MusicTracksScreen.kt | 17 ++ .../loggedIn/music/MusicTracksTopBar.kt | 12 + .../loggedIn/music/NewMusicTrackButton.kt | 12 + .../loggedIn/music/NewMusicTrackScreen.kt | 16 ++ 8 files changed, 514 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index ae829eddc9..db8d7fa852 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -40,6 +40,7 @@ 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 @@ -49,27 +50,36 @@ 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 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.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.LoadAddressableNote 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.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 @@ -451,3 +461,202 @@ private fun formatTrackDuration(seconds: Int): String { val secs = seconds % 60 return "%d:%02d".format(minutes, secs) } + +// --------------------------------------------------------------------------- +// @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 MyAsyncImage falls back to the deterministic +// DefaultImageHeader robohash — 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 previewPlaylistTrack( + dTag: String, + title: String, + artist: String, + duration: Int, +): MusicTrackEvent = + MusicTrackEvent( + id = "ptrack_${dTag}_${title.hashCode()}".padEnd(64, '0').take(64), + pubKey = PREVIEW_PLAYLIST_PUBKEY, + createdAt = 1_730_000_000L, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("title", title), + arrayOf("artist", artist), + arrayOf("url", "https://example.invalid/$dTag.mp3"), + arrayOf("duration", duration.toString()), + arrayOf("t", "music"), + ), + content = "", + sig = PREVIEW_PLAYLIST_SIG, + ) + +private fun previewPlaylistEvent( + dTag: String, + title: String, + description: String, + tracks: List, + image: String? = "https://example.invalid/playlist.jpg", + isCollaborative: Boolean = false, + isPrivate: Boolean = false, + content: String = "", +): MusicPlaylistEvent { + val tags = + buildList { + add(arrayOf("d", dTag)) + add(arrayOf("title", title)) + add(arrayOf("description", description)) + image?.let { add(arrayOf("image", it)) } + add(arrayOf("t", "playlist")) + tracks.forEach { track -> + add(arrayOf("a", "${MusicTrackEvent.KIND}:${track.pubKey}:${track.dTag()}")) + } + if (isPrivate) add(arrayOf("private", "true")) else add(arrayOf("public", "true")) + if (isCollaborative) add(arrayOf("collaborative", "true")) + }.toTypedArray() + + return MusicPlaylistEvent( + id = "pl_${dTag}_${title.hashCode()}".padEnd(64, '0').take(64), + 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", + ) + + 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 = "", + ) + + 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, + ) + + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { + MusicPlaylistCover( + image = null, + note = it, + trackCount = 7, + accountViewModel = mockAccountViewModel(), + ) + } + } + } +} + +@Preview +@Composable +private fun MissingPlaylistTrackRowPreview() { + ThemeComparisonColumn { + MissingPlaylistTrackRow(position = 4) + } +} + +@Preview +@Composable +private fun TrackCoverPlaceholderPreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp)) { + TrackCoverPlaceholder() + } + } +} + +@Preview +@Composable +private fun PlaylistTagPreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + PlaylistTag(text = "Collaborative") + PlaylistTag(text = "Private") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 56a0657fe0..91c68d34e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -39,6 +39,7 @@ 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 @@ -47,29 +48,38 @@ 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.LoadThumbAndThenVideoView import com.vitorpamplona.amethyst.service.playback.composable.VideoView +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.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags 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 com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext private val COVER_ASPECT_RATIO = 1f @@ -350,3 +360,157 @@ private fun formatDuration(seconds: Int): String { val secs = seconds % 60 return "%d:%02d".format(minutes, secs) } + +// --------------------------------------------------------------------------- +// @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) + +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 = "track_${dTag}_${title.hashCode()}".padEnd(64, '0').take(64), + 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", + ) + + 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 = "", + ) + + 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") + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } + + ThemeComparisonColumn { + LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> + note?.let { MusicTrackCover(image = null, note = it, accountViewModel = mockAccountViewModel()) } + } + } +} + +@Preview +@Composable +private fun ExplicitBadgePreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp)) { + ExplicitBadge() + } + } +} + +@Preview +@Composable +private fun TopicChipPreview() { + ThemeComparisonColumn { + Row(modifier = Modifier.padding(8.dp)) { + TopicChip("electronic") + Spacer(Modifier.padding(start = 4.dp)) + TopicChip("synthwave") + Spacer(Modifier.padding(start = 4.dp)) + TopicChip("80s") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt index 7b916d4b45..14bd8510fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt @@ -44,16 +44,20 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardCapitalization +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.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.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Composable fun AddToMusicPlaylistSheet( @@ -184,3 +188,25 @@ private fun NewMusicPlaylistDialog( }, ) } + +@Preview +@Composable +private fun NewMusicPlaylistDialogPreview() { + ThemeComparisonColumn { + NewMusicPlaylistDialog(onDismiss = {}, onCreate = {}) + } +} + +@Preview +@Composable +private fun AddToMusicPlaylistSheetEmptyPreview() { + ThemeComparisonColumn { + AddToMusicPlaylistSheet( + // No matching addressable in LocalCache → the VM resolves zero playlists, + // which is the empty-state path we want to capture. + trackAddress = "36787:0000000000000000000000000000000000000000000000000000000000000000:preview-empty", + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt index b2e956c0e2..dab3da0491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextOverflow +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 @@ -45,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn /** * One row in the Add-to-Playlist sheet. Mirrors `BookmarkGroupManagementItem` so the visual @@ -176,3 +178,59 @@ private fun PlaylistToggleButton( } } } + +@Preview +@Composable +private fun MusicPlaylistManagementItemNotInPreview() { + ThemeComparisonColumn { + MusicPlaylistManagementItem( + playlistTitle = "Summer Vibes 2024", + isTrackInPlaylist = false, + totalTracks = 12, + onClick = {}, + onToggle = {}, + ) + } +} + +@Preview +@Composable +private fun MusicPlaylistManagementItemInPreview() { + ThemeComparisonColumn { + MusicPlaylistManagementItem( + playlistTitle = "Late-night Coding Beats", + isTrackInPlaylist = true, + totalTracks = 47, + onClick = {}, + onToggle = {}, + ) + } +} + +@Preview +@Composable +private fun MusicPlaylistManagementItemEmptyPreview() { + ThemeComparisonColumn { + MusicPlaylistManagementItem( + playlistTitle = "Fresh Picks", + isTrackInPlaylist = false, + totalTracks = 0, + onClick = {}, + onToggle = {}, + ) + } +} + +@Preview +@Composable +private fun MusicPlaylistManagementItemUntitledPreview() { + ThemeComparisonColumn { + MusicPlaylistManagementItem( + playlistTitle = "", + isTrackInPlaylist = false, + totalTracks = 3, + onClick = {}, + onToggle = {}, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt index fe330bf9f6..c5c4b1b636 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -23,6 +23,7 @@ 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 @@ -33,9 +34,12 @@ 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.theme.ThemeComparisonColumn @Composable fun MusicTracksScreen( @@ -118,3 +122,16 @@ fun WatchAccountForMusicTracksScreen( musicFeedState.checkKeysInvalidateDataAndSendToTop() } } + +@Preview +@Composable +private fun MusicTracksScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + MusicTracksScreen( + musicTracksFeedContentState = accountViewModel.feedStates.musicTracksFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt index 5270525b7f..00501e7f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt @@ -24,11 +24,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar 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( @@ -46,3 +50,11 @@ fun MusicTracksTopBar( ) } } + +@Preview +@Composable +private fun MusicTracksTopBarPreview() { + ThemeComparisonColumn { + MusicTracksTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt index 558c59d61e..9d05b125dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt @@ -25,15 +25,19 @@ 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( @@ -54,3 +58,11 @@ fun NewMusicTrackButton( ) } } + +@Preview +@Composable +private fun NewMusicTrackButtonPreview() { + ThemeComparisonRow { + NewMusicTrackButton(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt index cc45a021e7..f28c72fecb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt @@ -46,13 +46,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +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.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar 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 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -214,3 +218,15 @@ private fun DeleteMusicTrackRow( ) } } + +@Preview +@Composable +private fun NewMusicTrackScreenPreview() { + ThemeComparisonColumn { + NewMusicTrackScreen( + editDTag = null, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } +} From 62943d8f0a1e550067dd207f9a1836869e55913c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 11:10:09 +0000 Subject: [PATCH 08/27] fix(music): preserve tags on edit/toggle, lock concurrent toggles, search, mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass turned up several real bugs in the music feature. Rolled the fixes into one commit since they all touch the publish/edit path. Data-loss bugs (must-fix) - NewMusicTrackViewModel.publish() in edit mode rebuilt the event via MusicTrackEvent.build() with only the composer-visible fields, silently dropping every other tag the original event carried — video URL, released, track_number, format, bitrate, sample_rate, language, explicit, extra `t` genre tags, zap splits, anything custom. Adds a MusicTrackEvent.edit(earlierVersion, ...) companion that clones the existing TagArray and only mutates composer-managed fields, mirroring PinListEvent/BookmarkListEvent's `add`/`remove`/`resign` pattern. - AddToMusicPlaylistViewModel.toggle() and .createWithTrack() had the same problem for playlists. Adds MusicPlaylistEvent.addTrack / removeTrack companions that preserve the rest of the tag array. The VM now uses these instead of round-tripping through build(). Correctness bugs (must-fix) - MusicPlaylistEvent.build() emitted only one of `public`/`private` while isPublic() defaulted to `true`-when-absent. A private playlist therefore round-tripped as isPublic() && isPrivate(). isPublic() now falls back to !isPrivate(), and build() still emits the explicit pair so unrelated clients reading either flag agree on visibility. - AddToMusicPlaylistViewModel's `isWorking` flag wasn't a concurrency primitive — fast taps on different rows could race and the loser's broadcast would replace the winner's. Adds a Mutex around toggle / createWithTrack so they serialize. - The initial rescan() ran on the composition thread (init() is called from the sheet's body). Both initial and live re-scans now run on Dispatchers.IO inside the same Job, and the live collector filters bundles by kind so we don't re-walk LocalCache for every Text Note. - NewMusicTrackViewModel.init() set `isEditing = true` based purely on `editDTag != null`, so a stale dTag pointing at no cached event left the user on a Delete button that no-op'd. `isEditing` now derives from loadedEvent and falls back to create-mode when the lookup misses. - MusicTrackHeader synthesized mimeType "video/${format ?: "mp4"}" when a `video` URL was present — but `format` is the AUDIO format per spec, so a track with both `video=...mp4` and `format=mp3` emitted "video/mp3". Pass null when videoUrl wins and let ExoPlayer sniff. - MusicTracksFeedFilter only consulted params.match(), which checks the follow list but not mute/spammer/word lists. Muted authors leaked through. Mirror LongsFeedFilter and AND `account.isAcceptable(note)`. Search & idiomatic fixes (should-fix) - MusicTrackEvent + MusicPlaylistEvent now implement SearchableEvent so the local SQLite FTS indexes title/artist/album/description rather than only the JSON content. Searching "Pink Floyd" by artist now matches the local cache instead of waiting for relay results. - MusicTracksFeedFilter.feedKey() now class-prefixes with "music-" so it can't collide with other feeds that key off the same Home follow list. - MusicPlaylistEvent.build() renamed `description`/`shortDescription` to `content`/`description` so the parameter names match the spec. Nits - Drop trackCount() — callers prefer `trackAddresses().size`. - Drop six unused strings (composer placeholders for not-yet-wired Blossom audio/cover upload UI). - Wrap preview runBlocking { justConsume(...) } in remember{} so it runs once per preview key instead of every recompose. - Use a hex-shaped id for preview events instead of "track_xxx_yyy". --- .../amethyst/ui/note/types/MusicPlaylist.kt | 23 ++-- .../amethyst/ui/note/types/MusicTrack.kt | 21 +++- .../music/AddToMusicPlaylistViewModel.kt | 106 ++++++++++-------- .../loggedIn/music/NewMusicTrackViewModel.kt | 35 +++--- .../music/dal/MusicTracksFeedFilter.kt | 23 ++-- amethyst/src/main/res/values/strings.xml | 6 - .../music/playlist/MusicPlaylistEvent.kt | 77 +++++++++++-- .../music/track/MusicTrackEvent.kt | 58 +++++++++- 8 files changed, 251 insertions(+), 98 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index db8d7fa852..c85a433871 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -474,6 +474,11 @@ private fun formatTrackDuration(seconds: Int): String { 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, @@ -481,7 +486,7 @@ private fun previewPlaylistTrack( duration: Int, ): MusicTrackEvent = MusicTrackEvent( - id = "ptrack_${dTag}_${title.hashCode()}".padEnd(64, '0').take(64), + id = previewPlaylistHexId("ptrack-$dTag-$title"), pubKey = PREVIEW_PLAYLIST_PUBKEY, createdAt = 1_730_000_000L, tags = @@ -522,7 +527,7 @@ private fun previewPlaylistEvent( }.toTypedArray() return MusicPlaylistEvent( - id = "pl_${dTag}_${title.hashCode()}".padEnd(64, '0').take(64), + id = previewPlaylistHexId("pl-$dTag-$title"), pubKey = PREVIEW_PLAYLIST_PUBKEY, createdAt = 1_730_000_000L, tags = tags, @@ -549,10 +554,12 @@ private fun RenderMusicPlaylistPreview() { content = "My favorite summer vibes from 2024", ) - runBlocking { - withContext(Dispatchers.IO) { - tracks.forEach { LocalCache.justConsume(it, null, true) } - LocalCache.justConsume(event, null, true) + remember(event) { + runBlocking { + withContext(Dispatchers.IO) { + tracks.forEach { LocalCache.justConsume(it, null, true) } + LocalCache.justConsume(event, null, true) + } } } @@ -586,7 +593,7 @@ private fun RenderMusicPlaylistCollaborativePrivatePreview() { content = "", ) - runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } ThemeComparisonColumn { LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> @@ -616,7 +623,7 @@ private fun MusicPlaylistCoverPreview() { image = null, ) - runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } ThemeComparisonColumn { LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 91c68d34e2..12d9521e6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -139,8 +139,11 @@ fun MusicTrackHeader( val playableUri = videoUrl ?: url val mimeType = remember(format, videoUrl) { + // `format` per spec is the AUDIO format (mp3/flac/m4a/ogg). When a `video` URL is + // chosen, the audio-format hint doesn't apply — pass null and let ExoPlayer sniff + // the container itself instead of synthesizing nonsense like "video/mp3". when { - videoUrl != null -> "video/${format ?: "mp4"}" + videoUrl != null -> null format != null -> "audio/$format" else -> null } @@ -374,6 +377,14 @@ private fun formatDuration(seconds: Int): String { 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, @@ -403,7 +414,7 @@ private fun previewMusicTrackEvent( }.toTypedArray() return MusicTrackEvent( - id = "track_${dTag}_${title.hashCode()}".padEnd(64, '0').take(64), + id = previewHexId("track-$dTag-$title"), pubKey = PREVIEW_TRACK_PUBKEY, createdAt = 1_730_000_000L, tags = tags, @@ -428,7 +439,7 @@ private fun RenderMusicTrackPreview() { content = "Lyrics:\n[Verse 1]\nWalking through the city lights...\n\nProducer: John Doe", ) - runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } ThemeComparisonColumn { LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> @@ -460,7 +471,7 @@ private fun RenderMusicTrackExplicitPreview() { content = "", ) - runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } ThemeComparisonColumn { LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> @@ -482,7 +493,7 @@ private fun RenderMusicTrackExplicitPreview() { @Composable private fun MusicTrackCoverPreview() { val event = previewMusicTrackEvent(dTag = "cover-only", title = "Cover Only", artist = "Anon") - runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } + remember(event) { runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null, true) } } } ThemeComparisonColumn { LoadNote(baseNoteHex = event.address().toValue(), accountViewModel = mockAccountViewModel()) { note -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt index ef26b67a66..8cf392bdad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt @@ -34,6 +34,9 @@ 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 @@ -58,6 +61,14 @@ class AddToMusicPlaylistViewModel : ViewModel() { 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, @@ -66,13 +77,18 @@ class AddToMusicPlaylistViewModel : ViewModel() { this.account = accountViewModel.account this.trackAddress = AddressSerializer.parse(trackAddressTag) - rescan() - - // Re-scan when new events arrive — a playlist published from another screen, or one - // arriving fresh from a relay, should show up here without forcing the user to re-open. + // Both the initial scan and the live-event re-scan walk LocalCache.addressables, which + // is in-memory but still wants to be off the composition thread. The live scan also + // debounces by checking the bundle for our kind first so we don't re-walk on every + // unrelated event (text notes, reactions, etc). liveScanJob = viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { rescan() } + rescan() + LocalCache.live.newEventBundles.collect { bundle -> + if (bundle.any { it.event?.kind == MusicPlaylistEvent.KIND }) { + rescan() + } + } } } @@ -103,41 +119,31 @@ class AddToMusicPlaylistViewModel : ViewModel() { /** * Toggle membership: if the playlist already contains the track, drop it; otherwise append - * to the end. Either way we re-publish the playlist event with the same `dTag` so the - * addressable replaces the prior version. Returns false if no track was supplied (the route - * was opened in a malformed state). + * 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 - if (isWorking.value) return false - isWorking.value = true - try { - val existing = LocalCache.addressables.get(playlistAddress)?.event as? MusicPlaylistEvent ?: return false + return mutationLock.withLock { + if (isWorking.value) return@withLock false + isWorking.value = true + try { + val existing = LocalCache.addressables.get(playlistAddress)?.event as? MusicPlaylistEvent ?: return@withLock false - val current = existing.trackAddresses() - val nextTracks = - if (current.any { it == targetTrack }) { - current.filterNot { it == targetTrack } - } else { - current + targetTrack - } + val template = + if (existing.trackAddresses().any { it == targetTrack }) { + MusicPlaylistEvent.removeTrack(existing, targetTrack) + } else { + MusicPlaylistEvent.addTrack(existing, targetTrack) + } - account.signAndComputeBroadcast( - MusicPlaylistEvent.build( - title = existing.title().orEmpty(), - description = existing.content, - image = existing.image(), - shortDescription = existing.description(), - tracks = nextTracks, - isPrivate = existing.isPrivate(), - isCollaborative = existing.isCollaborative(), - dTag = existing.dTag(), - ), - ) - rescan() - return true - } finally { - isWorking.value = false + account.signAndComputeBroadcast(template) + withContext(Dispatchers.IO) { rescan() } + true + } finally { + isWorking.value = false + } } } @@ -145,20 +151,22 @@ class AddToMusicPlaylistViewModel : ViewModel() { suspend fun createWithTrack(title: String): Address? { val targetTrack = trackAddress ?: return null if (title.isBlank()) return null - if (isWorking.value) return null - isWorking.value = true - try { - val event = - account.signAndComputeBroadcast( - MusicPlaylistEvent.build( - title = title.trim(), - tracks = listOf(targetTrack), - ), - ) - rescan() - return event.address() - } finally { - isWorking.value = false + return mutationLock.withLock { + if (isWorking.value) return@withLock null + isWorking.value = true + try { + val event = + account.signAndComputeBroadcast( + MusicPlaylistEvent.build( + title = title.trim(), + tracks = listOf(targetTrack), + ), + ) + withContext(Dispatchers.IO) { rescan() } + event.address() + } finally { + isWorking.value = false + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt index a639593373..ca4b1d9787 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt @@ -33,8 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Address * and cover URLs (typically from a Blossom upload they ran separately). A future iteration * can wire NewMediaModel + GallerySelect here so the composer uploads the audio itself. * - * In edit mode (`editDTag` set), preserves the `d` tag so the published event replaces the - * earlier addressable instead of creating a new track. + * 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 @@ -51,11 +55,12 @@ class NewMusicTrackViewModel : ViewModel() { /** 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 to publish a NIP-09 deletion. */ + /** 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() = dTag != null + get() = loadedEvent != null fun init( accountViewModel: AccountViewModel, @@ -63,12 +68,12 @@ class NewMusicTrackViewModel : ViewModel() { ) { if (::account.isInitialized) return // idempotent across recompositions this.account = accountViewModel.account - dTag = editDTag - editDTag?.let { existingDTag -> - val existingAddress = Address(MusicTrackEvent.KIND, account.userProfile().pubkeyHex, existingDTag) + 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() @@ -78,6 +83,7 @@ class NewMusicTrackViewModel : ViewModel() { durationSeconds.value = existing.duration()?.toString().orEmpty() description.value = existing.content } + // If the lookup fails, dTag stays null and the screen renders as create-mode. } } @@ -87,7 +93,6 @@ class NewMusicTrackViewModel : ViewModel() { if (!isValid()) return false isPublishing.value = true try { - val effectiveDTag = dTag val parsedTitle = title.value.trim() val parsedArtist = artist.value.trim() val parsedUrl = audioUrl.value.trim() @@ -96,9 +101,11 @@ class NewMusicTrackViewModel : ViewModel() { val parsedDuration = durationSeconds.value.trim().toIntOrNull() val parsedDescription = description.value - account.signAndComputeBroadcast( - if (effectiveDTag != null) { - MusicTrackEvent.build( + val existing = loadedEvent + val template = + if (existing != null) { + MusicTrackEvent.edit( + earlierVersion = existing, title = parsedTitle, artist = parsedArtist, url = parsedUrl, @@ -106,7 +113,6 @@ class NewMusicTrackViewModel : ViewModel() { image = parsedCover, album = parsedAlbum, duration = parsedDuration, - dTag = effectiveDTag, ) } else { MusicTrackEvent.build( @@ -118,8 +124,9 @@ class NewMusicTrackViewModel : ViewModel() { album = parsedAlbum, duration = parsedDuration, ) - }, - ) + } + + account.signAndComputeBroadcast(template) return true } finally { isPublishing.value = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt index 6aaa3649af..2615addd61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt @@ -41,7 +41,9 @@ import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent class MusicTracksFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + // Class prefix avoids collisions with other feeds that key off the same Home follow list + // (Articles, Longs, etc) — the FeedContentState cache keys off feedKey(). + override fun feedKey(): String = "music-" + account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 200 @@ -59,8 +61,7 @@ class MusicTracksFeedFilter( val params = buildFilterParams(account) val notes = LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> - val noteEvent = it.event - noteEvent is MusicTrackEvent && params.match(noteEvent, it.relays) + accept(it, params) } return sort(notes) } @@ -75,11 +76,19 @@ class MusicTracksFeedFilter( private fun innerApplyFilter(collection: Collection): Set { val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } - return collection.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is MusicTrackEvent && params.match(noteEvent, it.relays) - } + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + // params.match() consults the follow list but not mute/spammer/word lists, so a muted + // author would leak through if we only relied on it. Mirror the LongsFeedFilter pattern. + return noteEvent is MusicTrackEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) } override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 02e7d5b1cb..4a5bba0d34 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -764,18 +764,12 @@ Album (optional) Duration in seconds (optional) Lyrics or credits (optional) - Publish Delete this track Publish a NIP-09 deletion for this music track? - Uploading audio… - Uploading cover… - Pick audio file - Pick cover image Add to playlist Add to playlist You don\'t own any playlists yet. Tap the button to create one. New playlist - New playlist Playlist name Create Playlist diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt index 5b2dbe4c18..69fe233fb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt @@ -29,14 +29,18 @@ import com.vitorpamplona.quartz.experimental.music.playlist.tags.PublicTag 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 @@ -49,17 +53,23 @@ class MusicPlaylistEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : 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) - fun isPublic() = tags.firstNotNullOfOrNull(PublicTag::parse) ?: true - + /** + * `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 + fun isPublic() = tags.firstNotNullOfOrNull(PublicTag::parse) ?: !isPrivate() + fun isCollaborative() = tags.firstNotNullOfOrNull(CollaborativeTag::parse) ?: false /** @@ -72,7 +82,12 @@ class MusicPlaylistEvent( if (address.kind == MusicTrackEvent.KIND) address else null } - fun trackCount(): Int = trackAddresses().size + 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 @@ -82,16 +97,18 @@ class MusicPlaylistEvent( @OptIn(ExperimentalUuidApi::class) fun build( title: String, - description: String = "", + /** Long-form description, written into `event.content` (the JSON `content` field). */ + content: String = "", image: String? = null, - shortDescription: String? = null, + /** Short description, written into the `description` tag. */ + description: String? = null, tracks: List
= emptyList(), isPrivate: Boolean = false, isCollaborative: Boolean = false, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, description, createdAt) { + ) = eventTemplate(KIND, content, createdAt) { dTag(dTag) alt("$ALT_DESCRIPTION_PREFIX: $title") @@ -99,10 +116,13 @@ class MusicPlaylistEvent( hashtag(CATEGORY_TAG) image?.let { image(it) } - shortDescription?.let { description(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 { @@ -112,5 +132,46 @@ class MusicPlaylistEvent( initializer() } + + /** + * Returns a replacement event with `trackAddress` appended to the end of the track list, + * preserving every other tag of `earlierVersion` (extra `t` genre tags, custom metadata, + * description, image, etc). If the track is already present, returns the earlier + * version's template unchanged. + */ + fun addTrack( + earlierVersion: MusicPlaylistEvent, + trackAddress: Address, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val aValue = AddressSerializer.assemble(trackAddress.kind, trackAddress.pubKeyHex, trackAddress.dTag) + val alreadyPresent = earlierVersion.tags.any { it.size > 1 && it[0] == ATag.TAG_NAME && it[1] == aValue } + val newTags = + earlierVersion.tags.builder { + if (!alreadyPresent) { + // `add` (not `addUnique`) so multiple a-tags accumulate in order. + add(arrayOf(ATag.TAG_NAME, aValue)) + } + } + return EventTemplate(createdAt, KIND, newTags, earlierVersion.content) + } + + /** + * Returns a replacement event with every `a` tag pointing at `trackAddress` removed. + * Preserves every other tag of `earlierVersion`. If the track wasn't present, the new + * tags equal the old tags. + */ + fun removeTrack( + earlierVersion: MusicPlaylistEvent, + trackAddress: Address, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val aValue = AddressSerializer.assemble(trackAddress.kind, trackAddress.pubKeyHex, trackAddress.dTag) + val newTags = + earlierVersion.tags.builder { + remove(ATag.TAG_NAME, aValue) + } + return EventTemplate(createdAt, KIND, newTags, earlierVersion.content) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt index e2a6d9d258..ed8a07b3cc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt @@ -38,10 +38,13 @@ 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 @@ -54,7 +57,16 @@ class MusicTrackEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : 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) @@ -131,5 +143,49 @@ class MusicTrackEvent( initializer() } + + /** + * Builds a replacement event from an existing one, preserving every tag the composer + * doesn't surface (e.g. `video`, `released`, `track_number`, `format`, `bitrate`, + * `sample_rate`, `language`, `explicit`, extra `t` genre tags, `zap` splits, etc). + * + * `title`, `artist`, and `url` always replace. The composer-managed optional fields + * (`image`, `album`, `duration`) follow "null/blank = delete the tag" — the composer + * is authoritative for the fields it owns, so a user clearing the album field means + * "remove the album tag", not "leave whatever was there". The new event keeps the + * same `d` tag as `earlierVersion`, so relays treat the publish as the next version of + * the same addressable. Always re-derives `alt` from the new title+artist. + */ + fun edit( + earlierVersion: MusicTrackEvent, + title: String, + artist: String, + url: String, + description: String, + image: String?, + album: String?, + duration: Int?, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val newTags = + earlierVersion.tags.builder { + title(title) + artist(artist) + url(url) + alt("$ALT_DESCRIPTION_PREFIX: $title by $artist") + setOrRemove(image, ImageTag.TAG_NAME, ::image) + setOrRemove(album, AlbumTag.TAG_NAME, ::album) + if (duration != null) duration(duration) else remove(DurationTag.TAG_NAME) + } + return EventTemplate(createdAt, KIND, newTags, description) + } + + private fun TagArrayBuilder.setOrRemove( + value: String?, + tagName: String, + setter: (String) -> Unit, + ) { + if (value.isNullOrBlank()) remove(tagName) else setter(value) + } } } From 9724931355720fc1092268f46d7ece52235c0bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 13:10:13 +0000 Subject: [PATCH 09/27] feat(music): voice-style audio player for audio-only tracks + follow-list spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related polish passes after comparing the music UI against the rest of the codebase. 1) Voice-message audio player wins for audio-only tracks The Voice (NIP-A0) renderer's RenderAudioWithWaveform is the right playback widget for a kind-36787 track that has no `video` URL — it's ExoPlayer-backed (same as VideoView) but exposes: - inline tap-to-show controls with play/pause centered - top buttons for Share / Save-to-gallery / Picture-in-Picture / Mute - a 100dp compact strip (vs. VideoView's 16:9 video surface that leaves a blank rectangle for audio-only streams) - waveform-aware (we pass null since kind 36787 has no `waveform` tag) The new layout for an audio-only track: - album-art cover at the top (square, 1:1) - compact audio player below - title / artist / meta below that When a `video` URL is set the track keeps the full VideoView path — that's a real music-video file and deserves the video surface. 2) Music feed top bar now has a follow-list spinner Every sibling feed (Articles, Longs, Polls, Pictures, etc) exposes a FeedFilterSpinner backed by its own settings flag. The music screen previously only showed a static "Music" title and silently reused the Home follow list, so users couldn't filter the music feed independent of Home. - AccountSettings: defaultMusicTracksFollowList (defaults to Global) + changeDefaultMusicTracksFollowList(name) setter pair. - Account: liveMusicTracksFollowLists + …PerRelay flows, sourced from the new setting via the existing topNavFilterFlow machinery. - MusicTracksFeedFilter switches its filter params + feedKey to the dedicated flow. - MusicTracksTopBar swaps the static Text for FeedFilterSpinner with the same kind3GlobalPeopleRoutes catalog Articles/Longs use (All Follows, Your Follows, kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list). - WatchAccountForMusicTracksScreen now watches liveMusicTracksFollowLists so list switches invalidate the feed. --- .../vitorpamplona/amethyst/model/Account.kt | 3 ++ .../amethyst/model/AccountSettings.kt | 12 +++++ .../amethyst/ui/note/types/MusicTrack.kt | 42 ++++++++++------- .../loggedIn/music/MusicTracksScreen.kt | 2 +- .../loggedIn/music/MusicTracksTopBar.kt | 45 ++++++++++++++----- .../music/dal/MusicTracksFeedFilter.kt | 15 +++---- 6 files changed, 82 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 17b4b79346..a4063f3427 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -514,6 +514,9 @@ class Account( val liveArticlesFollowLists: StateFlow = topNavFilterFlow(settings.defaultArticlesFollowList) val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow + val liveMusicTracksFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicTracksFollowList) + val liveMusicTracksFollowListsPerRelay = OutboxLoaderState(liveMusicTracksFollowLists, cache, scope).flow + val liveBadgesFollowLists: StateFlow = topNavFilterFlow(settings.defaultBadgesFollowList) val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 6d80a12bc5..7f65981426 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -171,6 +171,7 @@ class AccountSettings( val defaultNestsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), + val defaultMusicTracksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), @@ -627,6 +628,17 @@ class AccountSettings( } } + fun changeDefaultMusicTracksFollowList(name: FeedDefinition) { + changeDefaultMusicTracksFollowList(name.code) + } + + fun changeDefaultMusicTracksFollowList(name: TopFilter) { + if (defaultMusicTracksFollowList.value != name) { + defaultMusicTracksFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultBadgesFollowList(name: FeedDefinition) { changeDefaultBadgesFollowList(name.code) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 12d9521e6d..50568e4061 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -136,25 +136,18 @@ fun MusicTrackHeader( .take(4) .toImmutableList() } - val playableUri = videoUrl ?: url - val mimeType = - remember(format, videoUrl) { - // `format` per spec is the AUDIO format (mp3/flac/m4a/ogg). When a `video` URL is - // chosen, the audio-format hint doesn't apply — pass null and let ExoPlayer sniff - // the container itself instead of synthesizing nonsense like "video/mp3". - when { - videoUrl != null -> null - format != null -> "audio/$format" - else -> null - } - } + // 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 (playableUri != null) { + 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 = playableUri, - mimeType = mimeType, + videoUri = videoUrl, + mimeType = null, title = title, thumbUri = image, authorName = note.author?.toBestDisplayName(), @@ -165,8 +158,8 @@ fun MusicTrackHeader( ) } else { VideoView( - videoUri = playableUri, - mimeType = mimeType, + videoUri = videoUrl, + mimeType = null, title = title, authorName = note.author?.toBestDisplayName(), roundedCorner = true, @@ -176,7 +169,22 @@ fun MusicTrackHeader( ) } } else { + // Audio-only path: render the cover art as a proper album-art square, then a + // compact voice-style audio player underneath. VoiceView's tap-to-play + share / + // save / PiP / mute controls are exactly what an MP3 stream wants — VideoView's + // 16:9 frame just leaves a blank rectangle next to the controls for audio. MusicTrackCover(image, note, accountViewModel) + if (url != null) { + RenderAudioWithWaveform( + mediaUrl = url, + title = title, + mimeType = audioMimeType, + waveform = null, // spec has no `waveform` tag for kind 36787 + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) + } } Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt index c5c4b1b636..9cfcd9d6a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -113,7 +113,7 @@ fun WatchAccountForMusicTracksScreen( musicFeedState: FeedContentState, accountViewModel: AccountViewModel, ) { - val listState by accountViewModel.account.liveHomeFollowLists.collectAsStateWithLifecycle() + val listState by accountViewModel.account.liveMusicTracksFollowLists.collectAsStateWithLifecycle() val hiddenUsers = accountViewModel.account.hiddenUsers.flow .collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt index 00501e7f91..d1f7e79cfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksTopBar.kt @@ -20,15 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontWeight +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 @@ -40,17 +43,39 @@ fun MusicTracksTopBar( nav: INav, ) { UserDrawerSearchTopBar(accountViewModel, nav) { - // MVP doesn't expose a dedicated follow-list spinner here — the feed inherits the - // user's Home follow list (see MusicTracksFeedFilter). When music gets its own - // setting, swap this for a FeedFilterSpinner like LongsTopBar / BadgesTopBar. - Text( - text = stringRes(R.string.route_music_tracks), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + 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() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt index 2615addd61..5c1fdffee7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicTracksFeedFilter.kt @@ -32,22 +32,19 @@ import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent /** * Pulls every MusicTrackEvent (kind 36787) in the addressable cache that passes the user's - * Home follow list and hidden/blocked rules. Reuses `liveHomeFollowLists` rather than - * defining a dedicated music-feed follow list so the MVP doesn't have to touch - * AccountSettings + Account state plumbing — the trade-off is that changing the Home - * filter also changes this feed, which mirrors what users already expect from the Home - * tab itself. + * dedicated music follow list and hidden/blocked rules. Backed by + * `account.settings.defaultMusicTracksFollowList` so the music screen's top-bar spinner + * stays in sync with this feed. */ class MusicTracksFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - // Class prefix avoids collisions with other feeds that key off the same Home follow list - // (Articles, Longs, etc) — the FeedContentState cache keys off feedKey(). + // 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.defaultHomeFollowList.value + fun followList(): TopFilter = account.settings.defaultMusicTracksFollowList.value fun TopFilter.isMuteList() = this is TopFilter.MuteList @@ -70,7 +67,7 @@ class MusicTracksFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - account.liveHomeFollowLists.value, + account.liveMusicTracksFollowLists.value, account.hiddenUsers.flow.value, ) From 5f2f70b0bffa277fe2c700a9a3ba4bd6793cdb5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 13:56:33 +0000 Subject: [PATCH 10/27] fix(music): subscribe to relays, fake waveform, dedupe hashtag row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups against testing the Scatman track event. 1) Music feed pulls from relays now. The "doesn't load anything even on Global" bug: there was no relay subscription wired for kind 36787 / 34139, so the feed only showed tracks that happened to already be in LocalCache (own publishes, direct nostr: links, hashtag visits). Mirrors the LongsFilterAssembler stack: - MusicTracksFilterAssembler + MusicTracksSubAssembler register a per-user-and-follow-list subscription on the music screen and whenever MUSIC_TRACKS is pinned to the bottom bar. - SubAssemblyHelper.makeMusicTracksFilter dispatches the active TopFilter to the right per-relay filter set: · Global → unscoped kind 36787/34139 from outbox/proxy relays · AllFollows / Authors / MutedAuthors → scoped to the active author set Hashtag/Geohash/Community variants fall through to emptyList for now; add per-case handlers when the spinner grows those routes. - RelaySubscriptionsCoordinator owns the assembler instance. - LocalPreferences persists defaultMusicTracksFollowList (key "defaultMusicTracksFollowList") so the spinner's choice survives a relaunch like every other feed. 2) Synthetic waveform replaces the empty audio strip. Kind 36787 has no `waveform` tag in the spec, so RenderAudioWithWaveform was given `null` and showed a flat audio bar. Now seeded off the track address: each track keeps the same decorative shape across recompositions, and a sine envelope + carrier + jitter makes the result read as "music waveform" rather than pure noise. ExoPlayer still owns playback progress — this is purely visual. 3) Hashtags now render in exactly one row. The Scatman test case (4 `t` tags including "music") was showing both DisplayUncitedHashtags (every `t`) and a TopicChip FlowRow (every `t` except the "music" genre marker), so the user saw visually-different duplicate chip rows. Dropped DisplayUncitedHashtags from MusicTrackHeader; TopicChips is the canonical music-aesthetic chip row and already excludes the genre marker. --- .../amethyst/LocalPreferences.kt | 5 + .../RelaySubscriptionsCoordinator.kt | 3 + .../amethyst/ui/note/types/MusicTrack.kt | 46 +++++++-- .../loggedIn/BottomBarFeedPreloaders.kt | 6 +- .../loggedIn/music/MusicTracksScreen.kt | 6 ++ .../datasource/MusicTracksFilterAssembler.kt | 50 ++++++++++ .../MusicTracksFilterAssemblerSubscription.kt | 48 ++++++++++ .../datasource/MusicTracksSubAssembler.kt | 96 +++++++++++++++++++ .../music/datasource/SubAssemblyHelper.kt | 50 ++++++++++ .../FilterMusicTracksByAuthors.kt | 95 ++++++++++++++++++ .../FilterMusicTracksByFollows.kt | 44 +++++++++ .../subassemblies/FilterMusicTracksGlobal.kt | 48 ++++++++++ 12 files changed, 484 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksGlobal.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 14ea7901c7..4af5208387 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -112,6 +112,7 @@ 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_BADGES_FOLLOW_LIST = "defaultBadgesFollowList" const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList" @@ -378,6 +379,7 @@ 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_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value)) putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value)) @@ -659,6 +661,7 @@ object LocalPreferences { defaultLiveStreamsFollowList = MutableStateFlow(followListPrefs.liveStreams), defaultNestsFollowList = MutableStateFlow(followListPrefs.nests), defaultLongsFollowList = MutableStateFlow(followListPrefs.longs), + defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks), defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles), defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges), defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets), @@ -732,6 +735,7 @@ object LocalPreferences { val nests: TopFilter, val longs: TopFilter, val articles: TopFilter, + val musicTracks: TopFilter, val badges: TopFilter, val browseEmojiSets: TopFilter, val communities: TopFilter, @@ -754,6 +758,7 @@ 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), badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine), browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 74a41668bb..f22ff48e3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -48,6 +48,7 @@ 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.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 +124,7 @@ class RelaySubscriptionsCoordinator( val nestRoomLiveness = NestRoomLivenessAssembler(client) val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) + val musicTracks = MusicTracksFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) @@ -156,6 +158,7 @@ class RelaySubscriptionsCoordinator( nestRoomLiveness, longs, articles, + musicTracks, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 50568e4061..f61812797b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground -import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -75,7 +74,6 @@ 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 com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -175,11 +173,17 @@ fun MusicTrackHeader( // 16:9 frame just leaves a blank rectangle next to the controls for audio. 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 fake is purely decorative — the underlying ExoPlayer + // owns playback progress, not these bars — but it gives the player a familiar + // "music waveform" shape instead of the empty bar Voice events show when their + // waveform is missing. + val syntheticWaveform = remember(note.idHex) { syntheticWaveformFor(note.idHex) } RenderAudioWithWaveform( mediaUrl = url, title = title, mimeType = audioMimeType, - waveform = null, // spec has no `waveform` tag for kind 36787 + waveform = syntheticWaveform, note = note, accountViewModel = accountViewModel, nav = nav, @@ -260,12 +264,9 @@ fun MusicTrackHeader( accountViewModel = accountViewModel, nav = nav, ) - - if (noteEvent.hasHashtags()) { - Row(Modifier.fillMaxWidth()) { - DisplayUncitedHashtags(noteEvent, it, callbackUri, accountViewModel, 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()) { @@ -372,6 +373,33 @@ private fun formatDuration(seconds: Int): String { return "%d:%02d".format(minutes, secs) } +private const val SYNTHETIC_WAVEFORM_SAMPLES = 96 + +/** + * Builds a stable, decorative waveform from the track id. We just need something more + * interesting to look at than a flat line; ExoPlayer continues to drive actual playback + * progress. Seeded so each track's bars stay constant across recompositions and screen + * re-entries, and shaped with two overlaid sines so the result reads as "music waveform" + * rather than pure noise. + */ +private fun syntheticWaveformFor(seed: String): com.vitorpamplona.amethyst.service.playback.composable.WaveformData { + val rng = kotlin.random.Random(seed.hashCode()) + val bars = + List(SYNTHETIC_WAVEFORM_SAMPLES) { index -> + val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES + val envelope = + kotlin.math + .sin(phase * Math.PI) + .toFloat() + .coerceAtLeast(0.25f) + val carrier = (kotlin.math.sin(phase * Math.PI * 8) * 0.25f + 0.5f).toFloat() + val jitter = rng.nextFloat() * 0.35f + 0.15f + (envelope * (carrier * 0.6f + jitter * 0.4f)).coerceIn(0.05f, 1.0f) + } + return com.vitorpamplona.amethyst.service.playback.composable + .WaveformData(bars) +} + // --------------------------------------------------------------------------- // @Preview composables. These wire a constructed MusicTrackEvent through // LocalCache so the renderer pulls the real Note path it would in production. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 63d342221d..0384cff489 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription @@ -95,10 +96,7 @@ private fun PreloadFor( NavBarItem.SHORTS -> ShortsFilterAssemblerSubscription(accountViewModel) - // No dedicated relay subscription yet — music tracks (kind 36787) arrive through the - // existing per-author home and profile subscriptions. When music gets its own - // FilterAssembler, replace this with `MusicTracksFilterAssemblerSubscription(...)`. - NavBarItem.MUSIC_TRACKS -> Unit + NavBarItem.MUSIC_TRACKS -> MusicTracksFilterAssemblerSubscription(accountViewModel) NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt index 9cfcd9d6a9..3420b802e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -39,6 +39,7 @@ 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 @@ -64,6 +65,11 @@ fun MusicTracksScreen( 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt new file mode 100644 index 0000000000..7751934347 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class MusicTracksQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class MusicTracksFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + MusicTracksSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..2672179207 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun MusicTracksFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + MusicTracksFilterAssemblerSubscription( + accountViewModel.dataSources().musicTracks, + accountViewModel, + ) +} + +@Composable +fun MusicTracksFilterAssemblerSubscription( + dataSource: MusicTracksFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + MusicTracksQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt new file mode 100644 index 0000000000..6c7267baf2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class MusicTracksSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: MusicTracksQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makeMusicTracksFilter(feedSettings, since, key.feedStates.musicTracksFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: MusicTracksQueryState) = key.account.userProfile() + + override fun list(key: MusicTracksQueryState) = key.listName() + + fun MusicTracksQueryState.listNameFlow() = account.settings.defaultMusicTracksFollowList + + fun MusicTracksQueryState.listName() = listNameFlow().value + + fun MusicTracksQueryState.followsPerRelayFlow() = account.liveMusicTracksFollowListsPerRelay + + fun MusicTracksQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: MusicTracksQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.musicTracksFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..64c38e194b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +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.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicTracksByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicTracksByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicTracksByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicTracksGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeMusicTracksFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllFollowsTopNavPerRelayFilterSet -> filterMusicTracksByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterMusicTracksByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterMusicTracksGlobal(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterMusicTracksByMutedAuthors(feedSettings, since, defaultSince) + // Hashtag / Geohash / Community / AllCommunities / SingleCommunity filters cover + // discovery-oriented routes the music feed doesn't expose yet — fall through to + // emptyList so we don't subscribe to junk. Add per-case handlers when the spinner + // grows those options. + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByAuthors.kt new file mode 100644 index 0000000000..9965fb99f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByAuthors.kt @@ -0,0 +1,95 @@ +/* + * 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 + +internal val MUSIC_TRACK_KINDS = listOf(MusicTrackEvent.KIND, MusicPlaylistEvent.KIND) + +fun filterMusicTracksByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = MUSIC_TRACK_KINDS, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterMusicTracksByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterMusicTracksByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterMusicTracksByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterMusicTracksByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByFollows.kt new file mode 100644 index 0000000000..ef505f64d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByFollows.kt @@ -0,0 +1,44 @@ +/* + * 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 filterMusicTracksByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { authors -> + filterMusicTracksByAuthors(relay, authors, sinceForRelay) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksGlobal.kt new file mode 100644 index 0000000000..704f96d01d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksGlobal.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.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 +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterMusicTracksGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo() + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = MUSIC_TRACK_KINDS, + limit = 200, + since = sinceForRelay, + ), + ) + } +} From e510fcceca7bd37fdc533b9f4f1a086998bda978 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:12:01 +0000 Subject: [PATCH 11/27] fix(music): synthetic waveform now actually varies per track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version drew envelope and carrier as pure functions of the sample position — so 60% of every bar was identical across all tracks and only the per-bar jitter (40% weight, narrow range) wiggled. Every track ended up with the same slow-fade-sine silhouette and only the noise pattern differed, which looked like every waveform was the same shape. Now every shape parameter — phase offset, carrier frequency, baseline, envelope strength, noise envelope — is drawn from the seeded RNG before the per-bar loop. Two different track ids produce visibly different waveforms: some have many tight bars, some few wide ones, some fade in and out, others stay flat. The seed is also bit-shuffled with a Knuth multiplicative constant XOR'd with the id length so two ids whose Java hashCode happens to collide still diverge. ExoPlayer still owns playback progress; the bars are purely decorative. --- .../amethyst/ui/note/types/MusicTrack.kt | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index f61812797b..2c2b6268f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -374,27 +374,41 @@ private fun formatDuration(seconds: Int): String { } 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. We just need something more - * interesting to look at than a flat line; ExoPlayer continues to drive actual playback - * progress. Seeded so each track's bars stay constant across recompositions and screen - * re-entries, and shaped with two overlaid sines so the result reads as "music waveform" - * rather than pure noise. + * 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): com.vitorpamplona.amethyst.service.playback.composable.WaveformData { - val rng = kotlin.random.Random(seed.hashCode()) + // 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 - val envelope = - kotlin.math - .sin(phase * Math.PI) - .toFloat() - .coerceAtLeast(0.25f) - val carrier = (kotlin.math.sin(phase * Math.PI * 8) * 0.25f + 0.5f).toFloat() - val jitter = rng.nextFloat() * 0.35f + 0.15f - (envelope * (carrier * 0.6f + jitter * 0.4f)).coerceIn(0.05f, 1.0f) + // 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 com.vitorpamplona.amethyst.service.playback.composable .WaveformData(bars) From d837132247ef6100069ab85c79d2048b4a6d2ec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:19:08 +0000 Subject: [PATCH 12/27] fix(music): drop boilerplate content, fuse cover+player, clickable hashtags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four UI polish fixes that all touch RenderMusicTrack: 1) Suppress the "Listen to my song …" auto-fill text. Several Blossom uploaders pre-populate the kind-36787 content field with that exact prefix — it just restates the title/artist that the card already shows above. MUSIC_TRACK_BOILERPLATE_PREFIXES is the one place to extend if more publishing tools start producing similar noise. 2) Cover and player now read as a single piece of UI. Previously the cover wore top-rounded corners and the audio player (via RenderAudioWithWaveform) used `imageModifier`'s all-rounded chrome plus a 5dp top padding — so two visually disconnected blocks sat above and below a gap. The audio player is now inlined: GetMediaItem → GetVideoController → RenderVoicePlayer with a custom border modifier that rounds only the bottom corners. No top padding. The result reads as one continuous card with the cover up top and the player below. 3) Internal DisplayUncitedHashtags row is gone. RenderAudioWithWaveform renders its own hashtag row for voice messages (where it's the only chip display). MusicTrack already renders TopicChips externally, so the inner row was producing a second, visually-different row of the same chips. Inlining the player chain (point 2) also drops that row. 4) TopicChip is clickable. Tapping `#electronic` now navigates to Route.Hashtag("electronic") — the same destination RichTextViewer's inline hashtags go to. The chip adds an optional `onClick` parameter so existing callers (and the preview block) keep working unchanged. --- .../amethyst/ui/note/types/MusicTrack.kt | 117 ++++++++++++++---- 1 file changed, 91 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index 2c2b6268f6..c04767e9a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -21,6 +21,7 @@ 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 @@ -31,6 +32,7 @@ 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 @@ -56,13 +58,17 @@ 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.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 @@ -81,6 +87,14 @@ 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, @@ -125,7 +139,16 @@ fun MusicTrackHeader( val duration = remember(noteEvent) { noteEvent.duration() } val format = remember(noteEvent) { noteEvent.format() } val isExplicit = remember(noteEvent) { noteEvent.isExplicit() } - val description = remember(noteEvent) { noteEvent.content.ifBlank { null } } + // 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 @@ -167,27 +190,60 @@ fun MusicTrackHeader( ) } } else { - // Audio-only path: render the cover art as a proper album-art square, then a - // compact voice-style audio player underneath. VoiceView's tap-to-play + share / - // save / PiP / mute controls are exactly what an MP3 stream wants — VideoView's - // 16:9 frame just leaves a blank rectangle next to the controls for audio. + // 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 fake is purely decorative — the underlying ExoPlayer - // owns playback progress, not these bars — but it gives the player a familiar - // "music waveform" shape instead of the empty bar Voice events show when their - // waveform is missing. + // 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) } - RenderAudioWithWaveform( - mediaUrl = url, - title = title, - mimeType = audioMimeType, - waveform = syntheticWaveform, - note = note, - accountViewModel = accountViewModel, - nav = nav, - ) + val callbackUri = remember(note) { note.toNostrUri() } + val playerBorder = + remember { + Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) + } + + Row( + Modifier.fillMaxWidth().height(100.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, + ) + } + } + } } } @@ -275,7 +331,9 @@ fun MusicTrackHeader( horizontalArrangement = Arrangement.spacedBy(Size5dp), verticalArrangement = Arrangement.spacedBy(Size5dp), ) { - topics.forEach { TopicChip(it) } + topics.forEach { topic -> + TopicChip(topic) { nav.nav(Route.Hashtag(topic.lowercase())) } + } } } } @@ -350,7 +408,18 @@ private fun ExplicitBadge() { } @Composable -private fun TopicChip(topic: String) { +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, @@ -359,11 +428,7 @@ private fun TopicChip(topic: String) { color = MaterialTheme.colorScheme.grayText, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) - .padding(horizontal = 8.dp, vertical = 2.dp), + modifier = base, ) } From 3e4f204d1fb7f2533974d0638185ef0395fcf3f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:35:40 +0000 Subject: [PATCH 13/27] feat(music): standalone Playlists feed + simpler add-to-playlist picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that go together. 1) Dedicated Playlists feed screen. A new MusicPlaylistsScreen sits alongside MusicTracksScreen with the same mechanics — FeedContentState, top-bar follow-list spinner, FAB, refreshable + lifecycle-aware feed loader — but renders kind-34139 playlists instead of kind-36787 tracks. Each row goes through NoteCompose so it picks up the existing RenderMusicPlaylist path (cover, track-count chip, descriptions, track rows). - MusicPlaylistsFeedFilter pulls kind 34139 from LocalCache.addressables, keyed on a new account-settings flag defaultMusicPlaylistsFollowList so the spinner choice is independent from the tracks feed. - Account exposes liveMusicPlaylistsFollowLists + …PerRelay. - LocalPreferences persists the new setting (key "defaultMusicPlaylistsFollowList") so it survives a relaunch. - The screen mounts MusicTracksFilterAssemblerSubscription — the same REQ already fetches both kinds, so opening the playlists tab on its own is enough to populate the cache. - NewMusicPlaylistFab opens an AlertDialog asking for just a name and publishes an empty MusicPlaylistEvent. Users then add tracks via the existing "Add to playlist" sheet on any track note. - Route.MusicPlaylists, NavBarItem.MUSIC_PLAYLISTS (in drawer Feeds section), ScrollStateKeys.MUSIC_PLAYLISTS_SCREEN, and the new strings route_music_playlists / new_music_playlist round out the wiring. 2) Add-to-playlist sheet stops imitating bookmarks. The previous sheet copied the bookmark-management pattern — TopAppBar, FAB → AlertDialog, ListItem rows with leading icon stack and a trailing round add/remove IconButton. That UI exists because bookmarks juggle a public/private split; playlists don't, so those affordances were just visual noise that didn't fit. The new sheet is a quick picker: - "Create new playlist" row inlined at the top (name field + Create). - LazyColumn of compact rows: a check-circle icon when the track is already in that playlist, the playlist-add icon when it isn't, playlist title, and the track count below in a small caption. - Tap anywhere on the row to toggle membership. - Bottom link "Manage all playlists" → Route.MusicPlaylists, so users who want richer management hop over to the new feed screen. Drops MusicPlaylistManagementItem.kt (bookmark-style ListItem) and the five strings that only existed for that file's leading-icon labels. --- .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountSettings.kt | 12 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 4 + .../loggedIn/BottomBarFeedPreloaders.kt | 6 +- .../loggedIn/music/AddToMusicPlaylistSheet.kt | 212 +++++++++++----- .../music/MusicPlaylistManagementItem.kt | 236 ------------------ .../music/MusicPlaylistsFeedLoaded.kt | 75 ++++++ .../loggedIn/music/MusicPlaylistsScreen.kt | 141 +++++++++++ .../loggedIn/music/MusicPlaylistsTopBar.kt | 84 +++++++ .../loggedIn/music/NewMusicPlaylistFab.kt | 129 ++++++++++ .../music/dal/MusicPlaylistsFeedFilter.kt | 89 +++++++ amethyst/src/main/res/values/strings.xml | 10 +- 17 files changed, 708 insertions(+), 312 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 4af5208387..d8cef19212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -113,6 +113,7 @@ private object PrefKeys { 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_BADGES_FOLLOW_LIST = "defaultBadgesFollowList" const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList" @@ -380,6 +381,7 @@ object LocalPreferences { 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_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value)) putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value)) @@ -662,6 +664,7 @@ object LocalPreferences { defaultNestsFollowList = MutableStateFlow(followListPrefs.nests), defaultLongsFollowList = MutableStateFlow(followListPrefs.longs), defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks), + defaultMusicPlaylistsFollowList = MutableStateFlow(followListPrefs.musicPlaylists), defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles), defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges), defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets), @@ -736,6 +739,7 @@ object LocalPreferences { val longs: TopFilter, val articles: TopFilter, val musicTracks: TopFilter, + val musicPlaylists: TopFilter, val badges: TopFilter, val browseEmojiSets: TopFilter, val communities: TopFilter, @@ -759,6 +763,7 @@ object LocalPreferences { 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), badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine), browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a4063f3427..b6e7c7a862 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -517,6 +517,9 @@ class Account( val liveMusicTracksFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicTracksFollowList) val liveMusicTracksFollowListsPerRelay = OutboxLoaderState(liveMusicTracksFollowLists, cache, scope).flow + val liveMusicPlaylistsFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList) + val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow + val liveBadgesFollowLists: StateFlow = topNavFilterFlow(settings.defaultBadgesFollowList) val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 7f65981426..23ea950c6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -172,6 +172,7 @@ class AccountSettings( val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultMusicTracksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultMusicPlaylistsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), @@ -639,6 +640,17 @@ class AccountSettings( } } + fun changeDefaultMusicPlaylistsFollowList(name: FeedDefinition) { + changeDefaultMusicPlaylistsFollowList(name.code) + } + + fun changeDefaultMusicPlaylistsFollowList(name: TopFilter) { + if (defaultMusicPlaylistsFollowList.value != name) { + defaultMusicPlaylistsFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultBadgesFollowList(name: FeedDefinition) { changeDefaultBadgesFollowList(name.code) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 1ccd2c4496..15c288ba0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -76,6 +76,7 @@ object ScrollStateKeys { const val LONGS_SCREEN = "LongsFeed" const val ARTICLES_SCREEN = "ArticlesFeed" const val MUSIC_TRACKS_SCREEN = "MusicTracksFeed" + const val MUSIC_PLAYLISTS_SCREEN = "MusicPlaylistsFeed" const val SEARCH_SCREEN = "SearchFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e99777d6dc..306e1282ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowList 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 @@ -285,6 +286,7 @@ fun BuildNavigation( composableFromEnd { LongsScreen(accountViewModel, nav) } composableFromEnd { ArticlesScreen(accountViewModel, nav) } composableFromEnd { MusicTracksScreen(accountViewModel, nav) } + composableFromEnd { MusicPlaylistsScreen(accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index df6f2e93b0..bfdcc74dd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -55,6 +55,7 @@ enum class NavBarItem { CALENDAR_COLLECTIONS, SHORTS, MUSIC_TRACKS, + MUSIC_PLAYLISTS, PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, @@ -238,6 +239,13 @@ val NavBarCatalog: Map = 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, @@ -355,6 +363,7 @@ val DrawerFeedsItems: List = NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SHORTS, NavBarItem.MUSIC_TRACKS, + NavBarItem.MUSIC_PLAYLISTS, NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9da7b8cd73..f11d1fd788 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -144,6 +144,8 @@ sealed class Route { @Serializable object MusicTracks : Route() + @Serializable object MusicPlaylists : Route() + @Serializable data class NewMusicTrack( val dTag: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 1f464dc36f..340b5417fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -51,6 +51,7 @@ 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 @@ -114,6 +115,7 @@ class AccountFeedContentStates( 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) @@ -204,6 +206,7 @@ class AccountFeedContentStates( longsFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes) musicTracksFeed.updateFeedWith(newNotes) + musicPlaylistsFeed.updateFeedWith(newNotes) softwareAppsFeed.updateFeedWith(newNotes) calendarAppointmentsFeed.updateFeedWith(newNotes) @@ -261,6 +264,7 @@ class AccountFeedContentStates( longsFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes) musicTracksFeed.deleteFromFeed(newNotes) + musicPlaylistsFeed.deleteFromFeed(newNotes) softwareAppsFeed.deleteFromFeed(newNotes) calendarAppointmentsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 0384cff489..2336e72399 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -96,7 +96,11 @@ private fun PreloadFor( NavBarItem.SHORTS -> ShortsFilterAssemblerSubscription(accountViewModel) - NavBarItem.MUSIC_TRACKS -> MusicTracksFilterAssemblerSubscription(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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt index 14bd8510fa..da4b0fb3d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt @@ -20,45 +20,71 @@ */ 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.AlertDialog +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.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.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.ui.navigation.bottombars.FabBottomBarPadded +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.lists.list.NewListButton 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, @@ -68,8 +94,6 @@ fun AddToMusicPlaylistSheet( val vm: AddToMusicPlaylistViewModel = viewModel() vm.init(accountViewModel, trackAddress) - var creating by rememberSaveable { mutableStateOf(false) } - Scaffold( modifier = Modifier.fillMaxSize().recalculateWindowInsets(), topBar = { @@ -78,11 +102,6 @@ fun AddToMusicPlaylistSheet( nav = nav, ) }, - floatingActionButton = { - FabBottomBarPadded(nav) { - NewListButton(onClick = { creating = true }) - } - }, ) { contentPadding -> Column( modifier = @@ -93,29 +112,58 @@ fun AddToMusicPlaylistSheet( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - AddToMusicPlaylistBody(vm = vm, accountViewModel = accountViewModel, nav = nav) + CreatePlaylistRow(vm = vm, accountViewModel = accountViewModel) + HorizontalDivider() + PlaylistPickerBody(vm = vm, accountViewModel = accountViewModel) + HorizontalDivider() + ManageAllPlaylistsLink(nav = nav) } } - - if (creating) { - NewMusicPlaylistDialog( - onDismiss = { creating = false }, - onCreate = { name -> - accountViewModel.launchSigner { - if (vm.createWithTrack(name) != null) { - creating = false - } - } - }, - ) - } } @Composable -private fun AddToMusicPlaylistBody( +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, - nav: INav, ) { val playlists by vm.ownedPlaylists @@ -131,18 +179,14 @@ private fun AddToMusicPlaylistBody( LazyColumn( state = rememberLazyListState(), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), ) { itemsIndexed( items = playlists, key = { _, item -> item.address.toValue() }, ) { _, summary -> - MusicPlaylistManagementItem( - modifier = Modifier.fillMaxWidth().animateItem(), - playlistTitle = summary.title, - isTrackInPlaylist = summary.containsTrack, - totalTracks = summary.trackCount, - onClick = { nav.nav(Route.Note(summary.address.toValue())) }, + PlaylistPickerRow( + summary = summary, onToggle = { accountViewModel.launchSigner { vm.toggle(summary.address) @@ -154,46 +198,76 @@ private fun AddToMusicPlaylistBody( } @Composable -private fun NewMusicPlaylistDialog( - onDismiss: () -> Unit, - onCreate: (String) -> Unit, +private fun PlaylistPickerRow( + summary: OwnedPlaylistSummary, + onToggle: () -> 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(), + 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, ) - }, - 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)) - } - }, - ) + val count = summary.trackCount + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count_short, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } } -@Preview @Composable -private fun NewMusicPlaylistDialogPreview() { - ThemeComparisonColumn { - NewMusicPlaylistDialog(onDismiss = {}, onCreate = {}) +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, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt deleted file mode 100644 index dab3da0491..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt +++ /dev/null @@ -1,236 +0,0 @@ -/* - * 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.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.text.style.TextOverflow -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.stringRes -import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding -import com.vitorpamplona.amethyst.ui.theme.Size15Modifier -import com.vitorpamplona.amethyst.ui.theme.Size50Modifier -import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn - -/** - * One row in the Add-to-Playlist sheet. Mirrors `BookmarkGroupManagementItem` so the visual - * vocabulary matches the bookmark UI: leading icon + total count, headline = playlist title, - * supporting = membership text, trailing = round add/remove action button. Row click navigates - * to the playlist itself; the trailing button is the toggle. - */ -@Composable -fun MusicPlaylistManagementItem( - modifier: Modifier = Modifier, - playlistTitle: String, - isTrackInPlaylist: Boolean, - totalTracks: Int, - onClick: () -> Unit, - onToggle: () -> Unit, -) { - ListItem( - modifier = modifier.clickable(onClick = onClick), - headlineContent = { - Text( - text = playlistTitle.ifBlank { stringRes(R.string.music_playlist_untitled) }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - supportingContent = { - PlaylistMembershipStatus(isTrackInPlaylist = isTrackInPlaylist) - }, - leadingContent = { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - symbol = MaterialSymbols.MusicNote, - contentDescription = stringRes(R.string.music_playlist_icon_label), - modifier = Size50Modifier, - ) - Spacer(StdVertSpacer) - Text( - text = pluralStringResource(R.plurals.music_playlist_track_count_short, totalTracks, totalTracks), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - trailingContent = { - PlaylistToggleButton( - isTrackInPlaylist = isTrackInPlaylist, - onToggle = onToggle, - ) - }, - ) -} - -@Composable -private fun PlaylistMembershipStatus(isTrackInPlaylist: Boolean) { - Row( - modifier = HalfHalfVertPadding, - horizontalArrangement = SpacedBy5dp, - verticalAlignment = Alignment.CenterVertically, - ) { - val text = - if (isTrackInPlaylist) { - stringRes(R.string.music_playlist_presence_indicator) - } else { - stringRes(R.string.music_playlist_absence_indicator) - } - - val icon = - if (isTrackInPlaylist) { - MaterialSymbols.PlayCircle - } else { - MaterialSymbols.RemoveCircleOutline - } - - Icon( - symbol = icon, - contentDescription = text, - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = text, - overflow = TextOverflow.MiddleEllipsis, - maxLines = 1, - ) - } -} - -@Composable -private fun PlaylistToggleButton( - isTrackInPlaylist: Boolean, - onToggle: () -> Unit, -) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - IconButton( - onClick = onToggle, - modifier = - Modifier - .background( - color = - if (isTrackInPlaylist) { - MaterialTheme.colorScheme.errorContainer - } else { - MaterialTheme.colorScheme.primary - }, - shape = RoundedCornerShape(percent = 80), - ), - ) { - if (isTrackInPlaylist) { - Icon( - symbol = MaterialSymbols.RemoveCircleOutline, - contentDescription = stringRes(R.string.music_playlist_remove_action_desc), - tint = MaterialTheme.colorScheme.onErrorContainer, - ) - } else { - MaterialSymbols.AutoMirrored.PlaylistAdd.let { addSymbol -> - Icon( - symbol = addSymbol, - contentDescription = stringRes(R.string.music_playlist_add_action_desc), - tint = MaterialTheme.colorScheme.onPrimary, - ) - } - } - } - } -} - -@Preview -@Composable -private fun MusicPlaylistManagementItemNotInPreview() { - ThemeComparisonColumn { - MusicPlaylistManagementItem( - playlistTitle = "Summer Vibes 2024", - isTrackInPlaylist = false, - totalTracks = 12, - onClick = {}, - onToggle = {}, - ) - } -} - -@Preview -@Composable -private fun MusicPlaylistManagementItemInPreview() { - ThemeComparisonColumn { - MusicPlaylistManagementItem( - playlistTitle = "Late-night Coding Beats", - isTrackInPlaylist = true, - totalTracks = 47, - onClick = {}, - onToggle = {}, - ) - } -} - -@Preview -@Composable -private fun MusicPlaylistManagementItemEmptyPreview() { - ThemeComparisonColumn { - MusicPlaylistManagementItem( - playlistTitle = "Fresh Picks", - isTrackInPlaylist = false, - totalTracks = 0, - onClick = {}, - onToggle = {}, - ) - } -} - -@Preview -@Composable -private fun MusicPlaylistManagementItemUntitledPreview() { - ThemeComparisonColumn { - MusicPlaylistManagementItem( - playlistTitle = "", - isTrackInPlaylist = false, - totalTracks = 3, - onClick = {}, - onToggle = {}, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt new file mode 100644 index 0000000000..19dd062a3f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsFeedLoaded.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun MusicPlaylistsFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + // NoteCompose dispatches MusicPlaylistEvent → RenderMusicPlaylist, so each row + // gets the same cover / track-row presentation the thread view uses. + NoteCompose( + baseNote = item, + modifier = Modifier, + isBoostedNote = false, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt new file mode 100644 index 0000000000..059f1ead7f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt @@ -0,0 +1,141 @@ +/* + * 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 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, + ) + // Reuses MusicTracks' subscription because the same REQ already asks for both kinds + // 36787 + 34139 — opening the playlists screen alone is enough to populate the cache. + MusicTracksFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + MusicPlaylistsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.MusicPlaylists, nav, accountViewModel) { route -> + if (route == Route.MusicPlaylists) { + playlistsFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + floatingButton = { + FabBottomBarPadded(nav) { + NewMusicPlaylistFab(accountViewModel) + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(playlistsFeedContentState, true) { + SaveableFeedContentState(playlistsFeedContentState, scrollStateKey = ScrollStateKeys.MUSIC_PLAYLISTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = playlistsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "MusicPlaylistsFeed", + onLoaded = { loaded -> + MusicPlaylistsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForMusicPlaylistsScreen( + playlistsFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveMusicPlaylistsFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + playlistsFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun MusicPlaylistsScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + MusicPlaylistsScreen( + playlistsFeedContentState = accountViewModel.feedStates.musicPlaylistsFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsTopBar.kt new file mode 100644 index 0000000000..faaa8c1020 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsTopBar.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun MusicPlaylistsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultMusicPlaylistsFollowList + .collectAsStateWithLifecycle() + + MusicPlaylistsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultMusicPlaylistsFollowList, + ) + } +} + +@Composable +private fun MusicPlaylistsTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + // Same route catalog as MusicTracks (and Articles / Longs): All Follows, Your Follows, + // kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list. + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = onChange, + accountViewModel = accountViewModel, + ) +} + +@Preview +@Composable +private fun MusicPlaylistsTopBarPreview() { + ThemeComparisonColumn { + MusicPlaylistsTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt new file mode 100644 index 0000000000..eb3b8176d3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt @@ -0,0 +1,129 @@ +/* + * 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 + +@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. + accountViewModel.launchSigner { + accountViewModel.account.signAndComputeBroadcast( + MusicPlaylistEvent.build(title = name.trim()), + ) + dialogOpen = false + } + }, + ) + } +} + +@Composable +private fun NewEmptyPlaylistDialog( + onDismiss: () -> Unit, + onCreate: (String) -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.music_playlist_new_dialog_title)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { onCreate(name.trim()) }, + enabled = name.trim().isNotBlank(), + ) { + Text(stringRes(R.string.music_playlist_create_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} + +@Preview +@Composable +private fun NewMusicPlaylistFabPreview() { + ThemeComparisonRow { + NewMusicPlaylistFab(accountViewModel = mockAccountViewModel()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt new file mode 100644 index 0000000000..9417ff2782 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/dal/MusicPlaylistsFeedFilter.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent + +/** + * Pulls every MusicPlaylistEvent (kind 34139) in the addressable cache that passes the user's + * dedicated playlists follow list and hidden/blocked rules. Mirrors [MusicTracksFeedFilter] but + * keyed on its own follow-list setting so the playlists screen can be filtered independently + * of the tracks screen. + */ +class MusicPlaylistsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "playlists-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultMusicPlaylistsFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveMusicPlaylistsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } + + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + return noteEvent is MusicPlaylistEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4a5bba0d34..cf7f6836b0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -753,7 +753,9 @@ Videos Articles Music + Playlists New music track + New playlist Title Track title Artist @@ -768,16 +770,12 @@ Publish a NIP-09 deletion for this music track? Add to playlist Add to playlist - You don\'t own any playlists yet. Tap the button to create one. + You don\'t own any playlists yet. Use the field above to create one. + Manage all playlists New playlist Playlist name Create - Playlist (untitled playlist) - In this playlist - Not in this playlist - Add this track to the playlist - Remove this track from the playlist %1$d track %1$d tracks From 30b06773e897c5cad33819d488702f1e5d92daf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:42:38 +0000 Subject: [PATCH 14/27] fix(music): dedupe playlist description vs content when identical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some publishing tools copy the same blurb into both the `description` tag and the JSON `content` field of a kind-34139 playlist. The renderer was showing both, producing a duplicate paragraph stacked on itself. When the trimmed strings match (case-insensitively), suppress the short description tag and keep the content field — `content` goes through TranslatableRichTextViewer below, which handles Markdown and NIP-19 references, so it's the richer of the two to keep. --- .../amethyst/ui/note/types/MusicPlaylist.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index c85a433871..3a304974c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -118,8 +118,18 @@ fun MusicPlaylistHeader( ) { val title = remember(noteEvent) { noteEvent.title() } val image = remember(noteEvent) { noteEvent.image() } - val shortDescription = remember(noteEvent) { noteEvent.description() } + // 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() } From 0139eb207c7ededab236cf20b5f9340af73a6f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:49:02 +0000 Subject: [PATCH 15/27] fix(music): subscribe each playlist track to relays so they actually load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadAddressableNote only resolves the AddressableNote shell from cache — it doesn't kick off a relay query. So a freshly-arrived playlist whose track events weren't already in the cache sat on the "Loading…" placeholder row forever. For each resolved track shell, wire up EventFinderFilterAssemblerSubscription (same pattern ProfileBadgesScreen uses for badge definitions). Its NoteEventLoaderSubAssembler asks relays for the addressable's actual event, and EventWatcherSubAssembler picks up reactions / replies on top. The subscription is keyed on the AddressableNote and lifecycle-aware, so scrolling the playlist off-screen drops the watchers. --- .../vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index 3a304974c0..8298f10081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -59,6 +59,7 @@ 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.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -221,6 +222,13 @@ fun MusicPlaylistHeader( } LoadAddressableNote(address, accountViewModel) { trackNote -> if (trackNote != null) { + // Ask relays for the track event itself (and its + // reactions/replies) so a playlist whose tracks aren't + // already in cache populates instead of sitting on the + // "Loading…" placeholder. The subscription is keyed on + // the AddressableNote and lifecycle-aware, so it stops + // when the playlist scrolls off-screen. + EventFinderFilterAssemblerSubscription(trackNote, accountViewModel) PlaylistTrackRow( position = index + 1, trackNote = trackNote, From 0b083b75aa927a69686042a87be77e4e4cbc86c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:10:24 +0000 Subject: [PATCH 16/27] fix(music): playlists feed uses its own follow-list subscription The playlists screen was reusing the tracks subscription, which is keyed on the tracks follow list. If the user picked a different list in the playlists spinner, the REQ ignored it and the feed showed empty whenever the two lists disagreed. Add a dedicated MusicPlaylistsFilterAssembler/SubAssembler keyed on defaultMusicPlaylistsFollowList so the spinner change actually rewires the REQ. The filter still asks for both kinds 36787 + 34139 per relay so opening this screen alone is enough to populate cached tracks. --- .../RelaySubscriptionsCoordinator.kt | 3 + .../loggedIn/music/MusicPlaylistsScreen.kt | 10 +- .../MusicPlaylistsFilterAssembler.kt | 50 +++++++++ ...sicPlaylistsFilterAssemblerSubscription.kt | 48 ++++++++ .../datasource/MusicPlaylistsSubAssembler.kt | 106 ++++++++++++++++++ 5 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index f22ff48e3a..099218e5b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -48,6 +48,7 @@ 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 @@ -125,6 +126,7 @@ class RelaySubscriptionsCoordinator( 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) @@ -159,6 +161,7 @@ class RelaySubscriptionsCoordinator( longs, articles, musicTracks, + musicPlaylists, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt index 059f1ead7f..02f5aa5574 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt @@ -39,7 +39,7 @@ 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.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Composable @@ -65,9 +65,11 @@ fun MusicPlaylistsScreen( playlistsFeedState = playlistsFeedContentState, accountViewModel = accountViewModel, ) - // Reuses MusicTracks' subscription because the same REQ already asks for both kinds - // 36787 + 34139 — opening the playlists screen alone is enough to populate the cache. - MusicTracksFilterAssemblerSubscription(accountViewModel) + // Dedicated subscription keyed on the *playlists* follow list (the top-bar spinner + // writes to a different setting than the tracks screen), so changing this screen's + // list actually changes the REQ. The filter still asks for both kinds 36787 + 34139 + // so opening the playlists screen alone is enough to populate referenced tracks. + MusicPlaylistsFilterAssemblerSubscription(accountViewModel) DisappearingScaffold( isInvertedLayout = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt new file mode 100644 index 0000000000..90a17ec475 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class MusicPlaylistsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class MusicPlaylistsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + MusicPlaylistsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..62010d5915 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun MusicPlaylistsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + MusicPlaylistsFilterAssemblerSubscription( + accountViewModel.dataSources().musicPlaylists, + accountViewModel, + ) +} + +@Composable +fun MusicPlaylistsFilterAssemblerSubscription( + dataSource: MusicPlaylistsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + MusicPlaylistsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt new file mode 100644 index 0000000000..4400edb788 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource + +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 both kinds (36787 + 34139) per relay so the local cache also picks up any tracks + * referenced by playlists in the same round-trip. + */ +class MusicPlaylistsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: MusicPlaylistsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makeMusicTracksFilter(feedSettings, since, key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: MusicPlaylistsQueryState) = key.account.userProfile() + + override fun list(key: MusicPlaylistsQueryState) = key.listName() + + fun MusicPlaylistsQueryState.listNameFlow() = account.settings.defaultMusicPlaylistsFollowList + + fun MusicPlaylistsQueryState.listName() = listNameFlow().value + + fun MusicPlaylistsQueryState.followsPerRelayFlow() = account.liveMusicPlaylistsFollowListsPerRelay + + fun MusicPlaylistsQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: MusicPlaylistsQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} From a403f44d8b3df1d5b048119c4c1e07dbdd942e37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 15:36:30 +0000 Subject: [PATCH 17/27] fix(music): observe playlist and track notes so cards update on relay arrival MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playlist header and each PlaylistTrackRow were reading note.event synchronously, so when a fresh playlist (or a track referenced by the playlist) arrived from a relay after first composition, the row stayed on its 'Unknown track' / stale-snapshot placeholder until the parent recomposed for some other reason. Switch both to observeNoteEvent, which subscribes to the note's metadata flow AND drives the EventFinderFilterAssembler — so a relay delivering the event both updates the local cache and triggers recomposition on the same call. --- .../amethyst/ui/note/types/MusicPlaylist.kt | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index 8298f10081..3fdfe4f347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -59,7 +60,7 @@ 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.EventFinderFilterAssemblerSubscription +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 @@ -94,10 +95,15 @@ fun RenderMusicPlaylist( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? MusicPlaylistEvent ?: return + // Observe so the card recomposes when a newer version of this playlist arrives from + // a relay (e.g. tracks added/removed, title changed). observeNoteEvent also drives + // the underlying EventFinderFilterAssembler subscription, so the playlist body itself + // gets fetched from relays if only the address was known. + val noteEvent by observeNoteEvent(note, accountViewModel) + val event = noteEvent ?: return MusicPlaylistHeader( - noteEvent = noteEvent, + noteEvent = event, note = note, makeItShort = makeItShort, canPreview = canPreview, @@ -222,13 +228,6 @@ fun MusicPlaylistHeader( } LoadAddressableNote(address, accountViewModel) { trackNote -> if (trackNote != null) { - // Ask relays for the track event itself (and its - // reactions/replies) so a playlist whose tracks aren't - // already in cache populates instead of sitting on the - // "Loading…" placeholder. The subscription is keyed on - // the AddressableNote and lifecycle-aware, so it stops - // when the playlist scrolls off-screen. - EventFinderFilterAssemblerSubscription(trackNote, accountViewModel) PlaylistTrackRow( position = index + 1, trackNote = trackNote, @@ -325,7 +324,12 @@ private fun PlaylistTrackRow( accountViewModel: AccountViewModel, nav: INav, ) { - val trackEvent = trackNote.event as? MusicTrackEvent + // Observe the track note so each row recomposes when the track event arrives from a + // relay (cards switch from "Unknown track" placeholder to the real title/artist/cover) + // or a newer revision replaces the cached one. observeNoteEvent also drives the + // EventFinderFilterAssembler subscription, so the row pulls the track itself if only + // the address is known. + val trackEvent by observeNoteEvent(trackNote, accountViewModel) val title = trackEvent?.title() ?: stringRes(R.string.music_playlist_unknown_track) val artist = trackEvent?.artist() val duration = trackEvent?.duration() From 93a27abf23e8f14f1059d74562901af92ba6ef85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 16:28:10 +0000 Subject: [PATCH 18/27] fix(music): playlist cover chip no longer collides with author avatar When a playlist has no `image` tag, the cover falls back to the author's profile banner with their avatar baked into the bottom-left by DefaultImageHeader. The track-count chip was also pinned to BottomStart, so it landed on top of the avatar. Render the banner alone (DefaultImageBanner) and place the avatar and the chip side-by-side in a single bottom row instead. Extract the chip into a small TrackCountChip composable so both branches share the same visual. --- .../amethyst/ui/note/types/MusicPlaylist.kt | 88 +++++++++++++------ 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index 3fdfe4f347..3a5ff4095d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -67,12 +67,16 @@ 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.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.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.grayText @@ -285,38 +289,70 @@ private fun MusicPlaylistCover( onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, ) - } else { - DefaultImageHeader(note, accountViewModel, imageModifier) - } - Box( - modifier = - Modifier - .align(Alignment.BottomStart) - .padding(12.dp) - .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, - ) + // When a real cover loads, the chip floats on top of the artwork in the + // bottom-left corner — no avatar to collide with. + TrackCountChip( + trackCount = trackCount, + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + ) + } else { + // No cover artwork: render the author's banner alone (DefaultImageBanner instead + // of DefaultImageHeader, which would bake its own avatar into BottomStart and + // collide with the chip). Then place the avatar and chip in a single bottom-row + // so they sit side-by-side instead of stacking. + 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, From 5a4cb5cc16caa0965fb8e9cc0d8321dd51ba8484 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 17:21:00 +0000 Subject: [PATCH 19/27] fix(music): apply avatar+chip pattern to loading and error covers too The previous fix only ported the no-cover branch. Loading and error fallbacks were still rendering banner-only with the chip floating alone, hiding the author's avatar entirely whenever a cover URL was set but hadn't loaded. Drop the MyAsyncImage wrapper here and drive SubcomposeAsyncImage directly so every painter state picks its own overlay: - Success: real cover + floating chip alone (no avatar to collide with) - Loading: blurred banner + avatar+chip side-by-side - Error / no image: banner + avatar+chip side-by-side --- .../amethyst/ui/note/types/MusicPlaylist.kt | 117 ++++++++++++------ 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index 3a5ff4095d..d7da822f83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -24,6 +24,7 @@ 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.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -40,6 +41,7 @@ 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 @@ -53,6 +55,9 @@ 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.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -71,8 +76,7 @@ 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.DefaultImageHeader -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +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 @@ -279,45 +283,82 @@ private fun MusicPlaylistCover( Box(imageModifier) { if (image != null) { - MyAsyncImage( - imageUrl = image, + // Driving SubcomposeAsyncImage directly (rather than going through MyAsyncImage) + // so each painter state can choose its own overlay: only `Success` floats the + // chip alone over the loaded artwork; `Loading` and `Error` fall back to the + // banner with the author's avatar + chip side-by-side — same treatment as the + // no-image case below — so the chip never sits on top of the avatar. + SubcomposeAsyncImage( + model = 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) }, - ) + modifier = Modifier.fillMaxSize(), + ) { + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent(imageModifier) + FloatingTrackCountChip(trackCount) + } - // When a real cover loads, the chip floats on top of the artwork in the - // bottom-left corner — no avatar to collide with. - TrackCountChip( - trackCount = trackCount, + is AsyncImagePainter.State.Error -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) + } + + else -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = true) + } + } + } + } else { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) + } + } +} + +@Composable +private fun BoxScope.FloatingTrackCountChip(trackCount: Int) { + TrackCountChip( + trackCount = trackCount, + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + ) +} + +/** + * 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(12.dp), - ) - } else { - // No cover artwork: render the author's banner alone (DefaultImageBanner instead - // of DefaultImageHeader, which would bake its own avatar into BottomStart and - // collide with the chip). Then place the avatar and chip in a single bottom-row - // so they sit side-by-side instead of stacking. - 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) - } + .padding(10.dp), + ) { + BaseUserPicture(author, Size55dp, accountViewModel, Modifier) + Spacer(Modifier.padding(start = 8.dp)) + TrackCountChip(trackCount = trackCount) } } } @@ -524,9 +565,9 @@ private fun formatTrackDuration(seconds: Int): String { // @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 MyAsyncImage falls back to the deterministic -// DefaultImageHeader robohash — that's what users see while the real cover -// is still loading. +// 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" From e876a162a49db249fe7eec44a8a9dbb7f85ab32e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 17:27:44 +0000 Subject: [PATCH 20/27] fix(music): review feedback round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isPublic() now always returns !isPrivate() so a playlist tagged with both public=true and private=true is consistently reported as private, matching the 'isPrivate wins' contract documented on isPrivate(). - AddToMusicPlaylistViewModel + NewMusicPlaylistFab: hop to Dispatchers.Main.immediate for every Compose State write. The wrapping coroutines (rescan loop, launchSigner) run on Dispatchers.IO; Snapshot tolerates off-main writes but the codebase convention is main-only. - MusicTracksSubAssembler + MusicPlaylistsSubAssembler: the single REQ asks both kinds 36787+34139, so the since cursor must be the min of both feeds' lastNoteCreatedAt to avoid over-fetching the lagging kind. Both assemblers now also listen to the other feed's cursor flow. - Extract formatTrackDuration into MusicFormatting.kt; MusicTrack and MusicPlaylist share it. - syntheticWaveformFor: replace inline FQN com.vitorpamplona.amethyst.service.playback.composable.WaveformData with an import. - NewMusicPlaylistFab: drop the second .trim() — the dialog's confirm button already trims before invoking onCreate. --- .../amethyst/ui/note/types/MusicFormatting.kt | 28 ++++++++++++++++ .../amethyst/ui/note/types/MusicPlaylist.kt | 6 ---- .../amethyst/ui/note/types/MusicTrack.kt | 14 +++----- .../music/AddToMusicPlaylistViewModel.kt | 33 ++++++++++++++----- .../loggedIn/music/NewMusicPlaylistFab.kt | 13 ++++++-- .../datasource/MusicPlaylistsSubAssembler.kt | 17 +++++++++- .../datasource/MusicTracksSubAssembler.kt | 18 +++++++++- .../music/playlist/MusicPlaylistEvent.kt | 9 +++-- 8 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicFormatting.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicFormatting.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicFormatting.kt new file mode 100644 index 0000000000..70c9814545 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicFormatting.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +/** `m:ss` formatting for a track length in seconds, shared by both MusicTrack and MusicPlaylist UI. */ +internal fun formatTrackDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index d7da822f83..4aa0ef7655 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -555,12 +555,6 @@ private fun PlaylistTag(text: String) { } } -private fun formatTrackDuration(seconds: Int): String { - val minutes = seconds / 60 - val secs = seconds % 60 - return "%d:%02d".format(minutes, secs) -} - // --------------------------------------------------------------------------- // @Preview composables. Constructs a MusicPlaylistEvent plus a handful of // MusicTrackEvents it references, pushes them through LocalCache, then diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index c04767e9a3..ffe8bf1945 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -62,6 +62,7 @@ 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 @@ -297,7 +298,7 @@ fun MusicTrackHeader( 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 = formatDuration(it)) } + duration?.let { MetaText(text = formatTrackDuration(it)) } if (duration != null && isExplicit) MetaSeparator() if (isExplicit) ExplicitBadge() } @@ -432,12 +433,6 @@ private fun TopicChip( ) } -private fun formatDuration(seconds: Int): String { - val minutes = seconds / 60 - val secs = seconds % 60 - return "%d:%02d".format(minutes, secs) -} - private const val SYNTHETIC_WAVEFORM_SAMPLES = 96 private const val TWO_PI = (Math.PI * 2).toFloat() @@ -452,7 +447,7 @@ private const val TWO_PI = (Math.PI * 2).toFloat() * 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): com.vitorpamplona.amethyst.service.playback.composable.WaveformData { +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()) @@ -475,8 +470,7 @@ private fun syntheticWaveformFor(seed: String): com.vitorpamplona.amethyst.servi val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength ((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f) } - return com.vitorpamplona.amethyst.service.playback.composable - .WaveformData(bars) + return WaveformData(bars) } // --------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt index 8cf392bdad..e3d251a29f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt @@ -97,7 +97,7 @@ class AddToMusicPlaylistViewModel : ViewModel() { super.onCleared() } - private fun rescan() { + private suspend fun rescan() { val mePubKey = account.userProfile().pubkeyHex val target = trackAddress val results = @@ -114,7 +114,12 @@ class AddToMusicPlaylistViewModel : ViewModel() { containsTrack = target != null && tracks.any { it == target }, ) }.sortedByDescending { it.containsTrack } - ownedPlaylists.value = results + // 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 + } } /** @@ -127,7 +132,7 @@ class AddToMusicPlaylistViewModel : ViewModel() { val targetTrack = trackAddress ?: return false return mutationLock.withLock { if (isWorking.value) return@withLock false - isWorking.value = true + setWorking(true) try { val existing = LocalCache.addressables.get(playlistAddress)?.event as? MusicPlaylistEvent ?: return@withLock false @@ -139,10 +144,10 @@ class AddToMusicPlaylistViewModel : ViewModel() { } account.signAndComputeBroadcast(template) - withContext(Dispatchers.IO) { rescan() } + rescan() true } finally { - isWorking.value = false + setWorking(false) } } } @@ -153,7 +158,7 @@ class AddToMusicPlaylistViewModel : ViewModel() { if (title.isBlank()) return null return mutationLock.withLock { if (isWorking.value) return@withLock null - isWorking.value = true + setWorking(true) try { val event = account.signAndComputeBroadcast( @@ -162,11 +167,23 @@ class AddToMusicPlaylistViewModel : ViewModel() { tracks = listOf(targetTrack), ), ) - withContext(Dispatchers.IO) { rescan() } + rescan() event.address() } finally { - isWorking.value = false + setWorking(false) } } } + + /** + * Publish the [isWorking] flag on the main thread. See the comment in [rescan]: this + * coroutine is launched on [Dispatchers.IO] (via `launchSigner`), and Compose State + * writes belong on the main dispatcher even when Snapshot would technically tolerate + * them off-main. + */ + private suspend fun setWorking(working: Boolean) { + withContext(Dispatchers.Main.immediate) { + isWorking.value = working + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt index eb3b8176d3..89e7ddb55b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistFab.kt @@ -48,6 +48,8 @@ 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) { @@ -72,12 +74,17 @@ fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) { onDismiss = { dialogOpen = false }, onCreate = { name -> // Empty playlist: user adds tracks later via the "Add to playlist" sheet on - // any music-track note. + // 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.trim()), + MusicPlaylistEvent.build(title = name), ) - dialogOpen = false + // 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 + } } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt index 4400edb788..ae36ac9730 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt @@ -53,7 +53,15 @@ class MusicPlaylistsSubAssembler( since: SincePerRelayMap?, ): List { val feedSettings = key.followsPerRelay() - return makeMusicTracksFilter(feedSettings, since, key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtIfFilled()) + // Same REQ asks both kinds (36787 + 34139), so the `since` cursor has to be the + // older of the two feeds' cursors — otherwise the kind whose feed lags would + // miss older events. + val defaultSince = + listOfNotNull( + key.feedStates.musicTracksFeed.lastNoteCreatedAtIfFilled(), + key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtIfFilled(), + ).minOrNull() + return makeMusicTracksFilter(feedSettings, since, defaultSince) } override fun user(key: MusicPlaylistsQueryState) = key.account.userProfile() @@ -91,6 +99,13 @@ class MusicPlaylistsSubAssembler( invalidateFilters() } }, + key.account.scope.launch(Dispatchers.IO) { + // REQ also asks for tracks (kind 36787), so pagination needs to react + // to the tracks feed's cursor advancing too. + key.feedStates.musicTracksFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt index 6c7267baf2..d4a0820a2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt @@ -43,7 +43,15 @@ class MusicTracksSubAssembler( since: SincePerRelayMap?, ): List { val feedSettings = key.followsPerRelay() - return makeMusicTracksFilter(feedSettings, since, key.feedStates.musicTracksFeed.lastNoteCreatedAtIfFilled()) + // One REQ asks both kinds (36787 + 34139), so the `since` cursor must cover both + // feeds — pick the older of the two `lastNoteCreatedAt`s so neither feed misses + // older events. If either side hasn't paged yet (null), the union is null too. + val defaultSince = + listOfNotNull( + key.feedStates.musicTracksFeed.lastNoteCreatedAtIfFilled(), + key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtIfFilled(), + ).minOrNull() + return makeMusicTracksFilter(feedSettings, since, defaultSince) } override fun user(key: MusicTracksQueryState) = key.account.userProfile() @@ -81,6 +89,14 @@ class MusicTracksSubAssembler( invalidateFilters() } }, + key.account.scope.launch(Dispatchers.IO) { + // The REQ also asks for playlists (kind 34139), so pagination needs to + // listen to the playlists feed's cursor as well — otherwise an older + // playlist that should be fetched stays out of view. + key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, ) return super.newSub(key) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt index 69fe233fb2..b573b72d22 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.experimental.music.playlist.tags.CollaborativeTa 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.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.nip01Core.core.Address @@ -68,7 +67,13 @@ class MusicPlaylistEvent( */ fun isPrivate() = tags.firstNotNullOfOrNull(PrivateTag::parse) ?: false - fun isPublic() = tags.firstNotNullOfOrNull(PublicTag::parse) ?: !isPrivate() + /** + * 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 From 71d50504fda2549e43b5b62456cdc4156c403fad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 17:52:28 +0000 Subject: [PATCH 21/27] feat(music): split tracks and playlists into separate filter pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up until now both screens shared one combined REQ that asked for both kinds (36787 + 34139). That meant a single 'makeMusicTracksFilter' had to cover both feeds, and the since cursor had to be the min of both feeds' lastNoteCreatedAt to avoid over-fetching. Split the pipeline so each screen owns its own kind: - filterMusicEventsByX helpers are parameterized by kinds: List - MUSIC_TRACK_KINDS = [36787], MUSIC_PLAYLIST_KINDS = [34139] - makeMusicTracksFilter(...) and makeMusicPlaylistsFilter(...) are thin wrappers picking the right kind list - MusicTracksSubAssembler / MusicPlaylistsSubAssembler each use their own helper and their own feed's cursor (no min-of-both) Also: - Cover the rest of the top-bar selectors that were previously falling through to emptyList: Hashtag, Geohash (Location), AllCommunities, SingleCommunity. Music feeds now respect all of them. - Drop TimeUtils.oneWeekAgo() floor from filterMusicEventsGlobal — music kinds are sparse on most relays, the floor silently hid older content the user hadn't seen yet. Tracks referenced by a playlist are still loaded on demand by each PlaylistTrackRow's own observeNoteEvent, so the playlists REQ no longer needs to bundle kind 36787. --- .../loggedIn/music/MusicPlaylistsScreen.kt | 7 +- .../datasource/MusicPlaylistsSubAssembler.kt | 23 +-- .../datasource/MusicTracksSubAssembler.kt | 20 +-- .../music/datasource/SubAssemblyHelper.kt | 59 +++++-- ...thors.kt => FilterMusicEventsByAuthors.kt} | 25 ++- .../FilterMusicEventsByCommunities.kt | 150 ++++++++++++++++++ ...llows.kt => FilterMusicEventsByFollows.kt} | 5 +- .../FilterMusicEventsByGeohashes.kt | 72 +++++++++ .../FilterMusicEventsByHashtag.kt | 69 ++++++++ ...ksGlobal.kt => FilterMusicEventsGlobal.kt} | 13 +- 10 files changed, 380 insertions(+), 63 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/{FilterMusicTracksByAuthors.kt => FilterMusicEventsByAuthors.kt} (78%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/{FilterMusicTracksByFollows.kt => FilterMusicEventsByFollows.kt} (93%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/{FilterMusicTracksGlobal.kt => FilterMusicEventsGlobal.kt} (79%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt index 02f5aa5574..399faca3ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistsScreen.kt @@ -66,9 +66,10 @@ fun MusicPlaylistsScreen( accountViewModel = accountViewModel, ) // Dedicated subscription keyed on the *playlists* follow list (the top-bar spinner - // writes to a different setting than the tracks screen), so changing this screen's - // list actually changes the REQ. The filter still asks for both kinds 36787 + 34139 - // so opening the playlists screen alone is enough to populate referenced tracks. + // 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt index ae36ac9730..fbd942aa97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsSubAssembler.kt @@ -41,8 +41,8 @@ import kotlinx.coroutines.launch * 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 both kinds (36787 + 34139) per relay so the local cache also picks up any tracks - * referenced by playlists in the same round-trip. + * 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, @@ -53,15 +53,9 @@ class MusicPlaylistsSubAssembler( since: SincePerRelayMap?, ): List { val feedSettings = key.followsPerRelay() - // Same REQ asks both kinds (36787 + 34139), so the `since` cursor has to be the - // older of the two feeds' cursors — otherwise the kind whose feed lags would - // miss older events. - val defaultSince = - listOfNotNull( - key.feedStates.musicTracksFeed.lastNoteCreatedAtIfFilled(), - key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtIfFilled(), - ).minOrNull() - return makeMusicTracksFilter(feedSettings, since, defaultSince) + // 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() @@ -99,13 +93,6 @@ class MusicPlaylistsSubAssembler( invalidateFilters() } }, - key.account.scope.launch(Dispatchers.IO) { - // REQ also asks for tracks (kind 36787), so pagination needs to react - // to the tracks feed's cursor advancing too. - key.feedStates.musicTracksFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() - } - }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt index d4a0820a2d..01ea758deb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksSubAssembler.kt @@ -43,15 +43,9 @@ class MusicTracksSubAssembler( since: SincePerRelayMap?, ): List { val feedSettings = key.followsPerRelay() - // One REQ asks both kinds (36787 + 34139), so the `since` cursor must cover both - // feeds — pick the older of the two `lastNoteCreatedAt`s so neither feed misses - // older events. If either side hasn't paged yet (null), the union is null too. - val defaultSince = - listOfNotNull( - key.feedStates.musicTracksFeed.lastNoteCreatedAtIfFilled(), - key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtIfFilled(), - ).minOrNull() - return makeMusicTracksFilter(feedSettings, since, defaultSince) + // 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() @@ -89,14 +83,6 @@ class MusicTracksSubAssembler( invalidateFilters() } }, - key.account.scope.launch(Dispatchers.IO) { - // The REQ also asks for playlists (kind 34139), so pagination needs to - // listen to the playlists feed's cursor as well — otherwise an older - // playlist that should be fetched stays out of view. - key.feedStates.musicPlaylistsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() - } - }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt index 64c38e194b..1c075e9c58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt @@ -22,29 +22,64 @@ 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.filterMusicTracksByAuthors -import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicTracksByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicTracksByMutedAuthors -import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicTracksGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsGlobal import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +/** + * Builds a per-relay filter set for the music *tracks* feed (kind 36787). + * Mirrors `makeLongsFilter`/`makeArticlesFilter` — every supported top-bar list selector + * (follows, authors, global, mute, hashtag, geohash, communities) maps to a dedicated + * sub-assembly. Anything not listed (e.g. future selectors we don't yet support) returns + * `emptyList` so we don't subscribe to junk. + */ fun makeMusicTracksFilter( feedSettings: IFeedTopNavPerRelayFilterSet, since: SincePerRelayMap?, defaultSince: Long? = null, +): List = makeMusicFilter(feedSettings, MUSIC_TRACK_KINDS, since, defaultSince) + +/** + * Builds a per-relay filter set for the music *playlists* feed (kind 34139). + * Same routing as [makeMusicTracksFilter] — only the kind list differs, so each screen's + * REQ asks for just the kind it actually renders instead of one combined REQ. + */ +fun makeMusicPlaylistsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makeMusicFilter(feedSettings, MUSIC_PLAYLIST_KINDS, since, defaultSince) + +private fun makeMusicFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, ): List = when (feedSettings) { - is AllFollowsTopNavPerRelayFilterSet -> filterMusicTracksByFollows(feedSettings, since, defaultSince) - is AuthorsTopNavPerRelayFilterSet -> filterMusicTracksByAuthors(feedSettings, since, defaultSince) - is GlobalTopNavPerRelayFilterSet -> filterMusicTracksGlobal(feedSettings, since, defaultSince) - is MutedAuthorsTopNavPerRelayFilterSet -> filterMusicTracksByMutedAuthors(feedSettings, since, defaultSince) - // Hashtag / Geohash / Community / AllCommunities / SingleCommunity filters cover - // discovery-oriented routes the music feed doesn't expose yet — fall through to - // emptyList so we don't subscribe to junk. Add per-case handlers when the spinner - // grows those options. + is AllCommunitiesTopNavPerRelayFilterSet -> filterMusicEventsByAllCommunities(feedSettings, kinds, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterMusicEventsByFollows(feedSettings, kinds, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterMusicEventsByAuthors(feedSettings, kinds, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterMusicEventsGlobal(feedSettings, kinds, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterMusicEventsByHashtag(feedSettings, kinds, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterMusicEventsByGeohashes(feedSettings, kinds, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterMusicEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterMusicEventsByCommunity(feedSettings, kinds, since, defaultSince) else -> emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByAuthors.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt index 9965fb99f2..6d59548196 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt @@ -30,10 +30,17 @@ 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 -internal val MUSIC_TRACK_KINDS = listOf(MusicTrackEvent.KIND, MusicPlaylistEvent.KIND) +// 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 filterMusicTracksByAuthors( +fun filterMusicEventsByAuthors( relay: NormalizedRelayUrl, + kinds: List, authors: Set, since: Long? = null, ): List { @@ -44,7 +51,7 @@ fun filterMusicTracksByAuthors( filter = Filter( authors = authorList, - kinds = MUSIC_TRACK_KINDS, + kinds = kinds, limit = 200, since = since, ), @@ -52,8 +59,9 @@ fun filterMusicTracksByAuthors( ) } -fun filterMusicTracksByAuthors( +fun filterMusicEventsByAuthors( authorSet: AuthorsTopNavPerRelayFilterSet, + kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, ): List { @@ -64,8 +72,9 @@ fun filterMusicTracksByAuthors( if (it.value.authors.isEmpty()) { null } else { - filterMusicTracksByAuthors( + filterMusicEventsByAuthors( relay = it.key, + kinds = kinds, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, ) @@ -73,8 +82,9 @@ fun filterMusicTracksByAuthors( }.flatten() } -fun filterMusicTracksByMutedAuthors( +fun filterMusicEventsByMutedAuthors( authorSet: MutedAuthorsTopNavPerRelayFilterSet, + kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, ): List { @@ -85,8 +95,9 @@ fun filterMusicTracksByMutedAuthors( if (it.value.authors.isEmpty()) { null } else { - filterMusicTracksByAuthors( + filterMusicEventsByAuthors( relay = it.key, + kinds = kinds, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt new file mode 100644 index 0000000000..c5bd165f40 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +/** + * Two REQs per relay: one asks for moderator approvals (NIP-72 kind 4550) whose `k` tag + * matches our music kinds, the other asks for the raw music events tagged with the + * community address. Mirrors the long-form / videos pattern so an unapproved track posted + * to a community still shows up. + */ +fun filterMusicEventsFromAllCommunities( + relay: NormalizedRelayUrl, + kinds: List, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + val kindsAsStrings = kinds.map { it.toString() } + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to kindsAsStrings, + ), + limit = communityList.size * 20, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("a" to communityList), + limit = communityList.size * 20, + since = since, + ), + ), + ) +} + +fun filterMusicEventsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterMusicEventsFromAllCommunities( + relay = it.key, + kinds = kinds, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} + +fun filterMusicEventsFromCommunity( + relay: NormalizedRelayUrl, + kinds: List, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authorList = authors?.sorted() + val kindsAsStrings = kinds.map { it.toString() } + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to kindsAsStrings, + ), + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = kinds, + tags = mapOf("a" to listOf(community)), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterMusicEventsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterMusicEventsFromCommunity( + relay = it.key, + kinds = kinds, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByFollows.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt index ef505f64d8..804b88f793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt @@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavP import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -fun filterMusicTracksByFollows( +fun filterMusicEventsByFollows( followsSet: AllFollowsTopNavPerRelayFilterSet, + kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, ): List { @@ -37,7 +38,7 @@ fun filterMusicTracksByFollows( listOfNotNull( it.value.authors?.let { authors -> - filterMusicTracksByAuthors(relay, authors, sinceForRelay) + filterMusicEventsByAuthors(relay, kinds, authors, sinceForRelay) }, ).flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt new file mode 100644 index 0000000000..6cd1449c1f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterMusicEventsByGeohashes( + relay: NormalizedRelayUrl, + kinds: List, + geotags: Set, + since: Long?, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterMusicEventsByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterMusicEventsByGeohashes( + relay = it.key, + kinds = kinds, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt new file mode 100644 index 0000000000..4af44740c5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterMusicEventsByHashtag( + relay: NormalizedRelayUrl, + kinds: List, + hashtags: Set, + since: Long? = null, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("t" to hashtags.sorted()), + limit = 200, + since = since, + ), + ), + ) + +fun filterMusicEventsByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterMusicEventsByHashtag( + relay = relayHashSet.key, + kinds = kinds, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksGlobal.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt index 704f96d01d..edd9f97d1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicTracksGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt @@ -24,22 +24,27 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayF 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.utils.TimeUtils -fun filterMusicTracksGlobal( +fun filterMusicEventsGlobal( relays: GlobalTopNavPerRelayFilterSet, + kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, ): List { if (relays.set.isEmpty()) return emptyList() return relays.set.map { - val sinceForRelay = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo() + // 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 = MUSIC_TRACK_KINDS, + kinds = kinds, limit = 200, since = sinceForRelay, ), From 82d2290e43daa24a81444ecafc467b206838a8f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 18:11:44 +0000 Subject: [PATCH 22/27] feat(music): cover image and audio file pickers on new-track composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-track composer was URL-only — users had to upload audio and artwork elsewhere and paste links. Match the New Badge composer flow instead: pick the files up front, Save uploads them through the user's default Blossom/NIP-96 server and publishes the kind 36787 event with the resulting URLs. Cover image picker is the first item on the screen (square upload tile, same style as Badge). Audio file picker sits below it. URL text fields stay below the pickers so power users who source audio from Wavlake / Stemstr / their own server can still paste links instead of uploading. ViewModel changes: - coverMedia / audioMedia: MultiOrchestrator slots fed by the pickers - saveAndPublish() runs cover upload then audio upload then publish; either failure short-circuits and surfaces a toast - isValid() accepts either a picked audio file or a non-blank URL - All Compose state writes from IO hop through Main.immediate Audio picker uses OpenDocument(audio/*) — we don't reuse the shared FileSelect because it also accepts application/pdf. --- .../loggedIn/music/NewMusicTrackScreen.kt | 240 ++++++++++++++- .../loggedIn/music/NewMusicTrackViewModel.kt | 278 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 7 + 3 files changed, 476 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt index f28c72fecb..6a3aa9a0c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt @@ -20,13 +20,25 @@ */ 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.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 @@ -39,17 +51,30 @@ 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.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.SavingTopBar @@ -57,6 +82,7 @@ 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 @@ -66,7 +92,37 @@ fun NewMusicTrackScreen( nav: INav, ) { val vm: NewMusicTrackViewModel = viewModel() - vm.init(accountViewModel, editDTag) + 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(picked) + }, + ) + } + + val isBusy = vm.isUploading.value || vm.isPublishing.value Scaffold( topBar = { @@ -74,11 +130,12 @@ fun NewMusicTrackScreen( titleRes = R.string.new_music_track, onCancel = { nav.popBack() }, onPost = { - accountViewModel.launchSigner { - if (vm.publish()) { - nav.popBack() - } - } + if (!vm.isValid() || isBusy) return@SavingTopBar + vm.saveAndPublish( + context = context, + onSuccess = { nav.popBack() }, + onError = accountViewModel.toastManager::toast, + ) }, ) }, @@ -96,6 +153,17 @@ fun NewMusicTrackScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + CoverImagePicker( + vm = vm, + accountViewModel = accountViewModel, + onPick = { wantsToPickCover = true }, + ) + + AudioFilePicker( + vm = vm, + onPick = { wantsToPickAudio = true }, + ) + OutlinedTextField( value = vm.title.value, onValueChange = { vm.title.value = it }, @@ -118,6 +186,8 @@ fun NewMusicTrackScreen( isError = vm.artist.value.isBlank(), ) + // Audio URL — pre-filled by the audio file uploader, also accepts pasted URLs + // (Wavlake, Stemstr, etc.) when the user doesn't have a local file to upload. OutlinedTextField( value = vm.audioUrl.value, onValueChange = { vm.audioUrl.value = it }, @@ -126,9 +196,13 @@ fun NewMusicTrackScreen( modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - isError = vm.audioUrl.value.isBlank(), + // Either an URL or a picked file is enough to satisfy the gate, so only flag + // the field as an error when both are missing. + isError = vm.audioUrl.value.isBlank() && vm.audioMedia.value == null, ) + // Cover URL — same logic as audio: filled by the cover uploader, but a pasted + // URL is fine too. Optional (no isError) because tracks can ship without art. OutlinedTextField( value = vm.coverUrl.value, onValueChange = { vm.coverUrl.value = it }, @@ -176,6 +250,158 @@ fun NewMusicTrackScreen( } } +@Composable +private fun CoverImagePicker( + vm: NewMusicTrackViewModel, + accountViewModel: AccountViewModel, + onPick: () -> Unit, +) { + val picked = vm.coverMedia.value + if (picked != null) { + // Tap the preview to swap to a different image — same gesture as the Badge composer. + Box(modifier = Modifier.clickable(onClick = onPick)) { + ShowImageUploadGallery( + list = picked, + onDelete = { 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, + ) + } +} + +@Composable +private fun AudioFilePicker( + vm: NewMusicTrackViewModel, + onPick: () -> Unit, +) { + val pickedName = vm.pickedAudioName.value + if (pickedName != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).clickable(onClick = onPick) + .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, + ) + } + 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, + ) + } +} + +/** + * 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, +) { + val baseModifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).clickable(onClick = onClick) + .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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt index ca4b1d9787..01ad82acb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt @@ -20,18 +20,32 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music +import android.content.Context 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.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.launch +import kotlinx.coroutines.withContext /** - * Composer for a kind-36787 Music Track. Keeps the form text-only for MVP — users paste audio - * and cover URLs (typically from a Blossom upload they ran separately). A future iteration - * can wire NewMediaModel + GallerySelect here so the composer uploads the audio itself. + * 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, @@ -51,6 +65,25 @@ class NewMusicTrackViewModel : ViewModel() { val durationSeconds = mutableStateOf("") val description = mutableStateOf("") val isPublishing = mutableStateOf(false) + val isUploading = mutableStateOf(false) + val uploadError = mutableStateOf(null) + + /** + * Two separate orchestrators because the cover image and the audio file go through + * different compression paths (the audio path skips image compression and the cover path + * goes through it). Sharing one orchestrator would force one path on both files. + */ + val coverMedia = mutableStateOf(null) + val audioMedia = mutableStateOf(null) + val pickedAudioName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + + val selectedServer = mutableStateOf(null) + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED — matches Badge's slider semantics. + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) /** Stable d-tag for the addressable: null = create-new, non-null = edit-existing. */ private var dTag: String? = null @@ -68,6 +101,8 @@ class NewMusicTrackViewModel : ViewModel() { ) { 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) @@ -87,55 +122,214 @@ class NewMusicTrackViewModel : ViewModel() { } } - fun isValid(): Boolean = title.value.isNotBlank() && artist.value.isNotBlank() && audioUrl.value.isNotBlank() + fun setPickedCover(uris: ImmutableList) { + coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } - suspend fun publish(): Boolean { - if (!isValid()) return false - isPublishing.value = true - try { - val parsedTitle = title.value.trim() - val parsedArtist = artist.value.trim() - val parsedUrl = audioUrl.value.trim() - val parsedCover = coverUrl.value.trim().ifBlank { null } - val parsedAlbum = album.value.trim().ifBlank { null } - val parsedDuration = durationSeconds.value.trim().toIntOrNull() - val parsedDescription = description.value + fun clearPickedCover() { + coverMedia.value = null + } - val existing = loadedEvent - val template = - if (existing != null) { - MusicTrackEvent.edit( - earlierVersion = existing, - title = parsedTitle, - artist = parsedArtist, - url = parsedUrl, - description = parsedDescription, - image = parsedCover, - album = parsedAlbum, - duration = parsedDuration, - ) - } else { - MusicTrackEvent.build( - title = parsedTitle, - artist = parsedArtist, - url = parsedUrl, - description = parsedDescription, - image = parsedCover, - album = parsedAlbum, - duration = parsedDuration, - ) + fun setPickedAudio(uri: SelectedMedia?) { + if (uri == null) { + audioMedia.value = null + pickedAudioName.value = null + } else { + 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('/') + } + } + + fun clearPickedAudio() = setPickedAudio(null) + + /** + * 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), then sign and broadcast the event with the + * resulting URLs. Returns true on success. The `Save` button on the screen is gated on + * [isValid] AND [isUploading] / [isPublishing], so the user can't double-trigger this. + */ + fun saveAndPublish( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + setUploading(true) + uploadError.value = null + + if (!performUploads(context, onError)) { + setUploading(false) + return@launch } - account.signAndComputeBroadcast(template) - return true - } finally { - isPublishing.value = false + setUploading(false) + setPublishing(true) + publishEvent() + withContext(Dispatchers.Main.immediate) { onSuccess() } + } catch (t: Throwable) { + // Surface the failure on the toast layer so the user sees what happened + // instead of silently sitting on a Save button that did nothing. + onError("Failed to save music track", t.message ?: t.javaClass.simpleName) + } finally { + setUploading(false) + setPublishing(false) + } } } + /** + * Runs the cover and audio uploads in sequence (not parallel — the audio path is much + * heavier and uploading them concurrently double-spikes RAM on the device). On success, + * the resulting URLs are written back into [coverUrl] / [audioUrl] so a retry after + * [publishEvent] failure doesn't re-upload the same files. + */ + private suspend fun performUploads( + context: Context, + onError: (String, String) -> Unit, + ): Boolean { + val acc = account + val server = + selectedServer.value ?: return run { + onError("No upload server selected", "Pick a media server in settings before uploading.") + false + } + val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value) + + coverMedia.value?.let { orch -> + val res = + orch.upload( + alt = title.value.ifBlank { null }, + contentWarningReason = null, + mediaQuality = quality, + server = server, + account = acc, + context = context, + useH265 = false, + stripMetadata = stripMetadata.value, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + if (!res.allGood) { + onError("Cover upload failed", formatUploadErrors(res.errors, context)) + return false + } + val url = + firstUploadedUrl(res.successful) ?: return run { + onError("Cover upload failed", "Server didn't return a URL for the uploaded cover.") + false + } + withContext(Dispatchers.Main.immediate) { + coverUrl.value = url + coverMedia.value = null + } + } + + audioMedia.value?.let { orch -> + val res = + orch.upload( + alt = title.value.ifBlank { null }, + contentWarningReason = null, + // Audio files aren't re-encoded by MediaCompressor (no codec path for + // them), so the quality slider is effectively no-op here; pass it through + // anyway for symmetry. + mediaQuality = quality, + server = server, + account = acc, + context = context, + useH265 = false, + stripMetadata = stripMetadata.value, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + if (!res.allGood) { + onError("Audio upload failed", formatUploadErrors(res.errors, context)) + return false + } + val url = + firstUploadedUrl(res.successful) ?: return run { + onError("Audio upload failed", "Server didn't return a URL for the uploaded audio.") + false + } + withContext(Dispatchers.Main.immediate) { + audioUrl.value = url + audioMedia.value = null + pickedAudioName.value = null + } + } + + // Remember the server choice for next time so the user doesn't have to pick it again. + acc.settings.changeDefaultFileServer(server) + acc.settings.changeStripLocationOnUpload(stripMetadata.value) + return true + } + + private fun firstUploadedUrl(successful: List): String? = + successful + .firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult } + ?.url + + private fun formatUploadErrors( + errors: List, + context: Context, + ): String = + errors + .map { context.getString(it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + + private suspend fun publishEvent() { + val parsedTitle = title.value.trim() + val parsedArtist = artist.value.trim() + val parsedUrl = audioUrl.value.trim() + val parsedCover = coverUrl.value.trim().ifBlank { null } + val parsedAlbum = album.value.trim().ifBlank { null } + val parsedDuration = durationSeconds.value.trim().toIntOrNull() + val parsedDescription = description.value + + val existing = loadedEvent + val template = + if (existing != null) { + MusicTrackEvent.edit( + earlierVersion = existing, + title = parsedTitle, + artist = parsedArtist, + url = parsedUrl, + description = parsedDescription, + image = parsedCover, + album = parsedAlbum, + duration = parsedDuration, + ) + } else { + MusicTrackEvent.build( + title = parsedTitle, + artist = parsedArtist, + url = parsedUrl, + description = parsedDescription, + image = parsedCover, + album = parsedAlbum, + duration = parsedDuration, + ) + } + + account.signAndComputeBroadcast(template) + } + suspend fun deleteLoaded(): Boolean { val target = loadedEvent ?: return false account.delete(target, emptySet()) return true } + + private suspend fun setUploading(value: Boolean) = withContext(Dispatchers.Main.immediate) { isUploading.value = value } + + private suspend fun setPublishing(value: Boolean) = withContext(Dispatchers.Main.immediate) { isPublishing.value = value } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 96107546ae..0a6bfa2ef9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1546,6 +1546,13 @@ Private Unknown track Loading track… + Upload a cover image + Pick a square image to be the artwork for this track. + Upload an audio file + Pick an MP3, WAV, or FLAC. It will be uploaded to your media server on Save. + Audio file ready to upload + Uploading… + Upload failed: %1$s Loading location From 4177390591beed897a93e1eea53955a562835ce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 18:18:11 +0000 Subject: [PATCH 23/27] feat(music): swap bookmark rows for playlist row in track dropdown menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Music tracks (kind 36787) belong in playlists (kind 34139), not in the generic bookmark list — but the dropdown menu was showing both, which made the bookmark rows feel like the default place to save tracks. Mirror the EmojiPack pattern instead: one curation flow per kind. For a MusicTrackEvent the Timestamp & Bookmarks section now shows only 'Add to playlist' (which opens the toggle sheet that already handles add/remove across every owned playlist). Everything else still gets the standard Manage / Private bookmark / Public bookmark trio. --- .../amethyst/ui/note/elements/DropDownMenu.kt | 109 ++++++++++-------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 2c31dc238d..cea9f92a61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -286,59 +286,70 @@ fun NoteDropDownMenu( } } } - // Music tracks (kind 36787) can additionally be added to one of the user's - // own playlists (kind 34139). This is independent of bookmarking — the menu - // still falls through to the bookmark rows below. - if (note.event is MusicTrackEvent && note is AddressableNote) { - M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.add_to_music_playlist)) { - nav.nav(Route.AddToMusicPlaylist(note.address.toValue())) - onDismiss() - } - } - // 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() + } + } } } } From ea227f1968d2cb19f475549a6875f3f2d41a923a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 18:33:22 +0000 Subject: [PATCH 24/27] fix(music): playlist cover chip is wrap-content again, not full-cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous cover used SubcomposeAsyncImage with a single content lambda that stacked the loaded image and the chip as BoxScope siblings. Under Coil's subcompose-layout pass the chip's wrap-content Box got measured to the cover's full size, painting a translucent black square over the whole artwork. Drop SubcomposeAsyncImage. Use rememberAsyncImagePainter + a plain Image + painter-state observation inside a normal Box. The image fills the box (Modifier.matchParentSize) and the chip sits as a normal BoxScope child with Modifier.align(BottomStart).padding(12.dp) — predictable wrap-content sizing. --- .../amethyst/ui/note/types/MusicPlaylist.kt | 75 +++++++++---------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt index 4aa0ef7655..1cea089e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -20,16 +20,15 @@ */ 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.BoxScope 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -56,8 +55,7 @@ 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.SubcomposeAsyncImage -import coil3.compose.SubcomposeAsyncImageContent +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 @@ -282,51 +280,46 @@ private fun MusicPlaylistCover( .clip(imageShape) Box(imageModifier) { - if (image != null) { - // Driving SubcomposeAsyncImage directly (rather than going through MyAsyncImage) - // so each painter state can choose its own overlay: only `Success` floats the - // chip alone over the loaded artwork; `Loading` and `Error` fall back to the - // banner with the author's avatar + chip side-by-side — same treatment as the - // no-image case below — so the chip never sits on top of the avatar. - SubcomposeAsyncImage( - model = image, - contentDescription = stringRes(R.string.preview_card_image_for, image), - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) { - val state by painter.state.collectAsState() - when (state) { - is AsyncImagePainter.State.Success -> { - SubcomposeAsyncImageContent(imageModifier) - FloatingTrackCountChip(trackCount) - } + 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() - is AsyncImagePainter.State.Error -> { - BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) - } + 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), + ) + } - else -> { - BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = true) - } + is AsyncImagePainter.State.Error -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) + } + + else -> { + BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = true) } } - } else { - BannerWithAuthorChip(note, trackCount, imageModifier, accountViewModel, blurred = false) } } } -@Composable -private fun BoxScope.FloatingTrackCountChip(trackCount: Int) { - TrackCountChip( - trackCount = trackCount, - modifier = - Modifier - .align(Alignment.BottomStart) - .padding(12.dp), - ) -} - /** * 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 From 9c9cf8844df077cdc5a9af1efa1c57849267f058 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 19:08:54 +0000 Subject: [PATCH 25/27] fix(music): save audio to MediaStore.Audio (not Video) and trim composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes in one: 1. MediaSaverToDisk crash when saving a music URL. The fall-through branch routed every non-image, non-PDF MIME to MediaStore.Video.EXTERNAL_CONTENT_URI; an audio/mpeg blob inserted into the Video collection fails with IllegalArgumentException. Add a dedicated audio/* branch that writes to MediaStore.Audio with DIRECTORY_MUSIC. 2. Drop the redundant cover-URL text field from the new-music-track composer. The upload tile at the top is the source of truth for the cover — having a second field at the bottom asked the user to set the same thing twice. ViewModel still threads coverUrl through to MusicTrackEvent.edit so editing an existing track keeps its image. 3. Tighten the new-music-track Scaffold's inset modifier order: padding(pad) -> consumeWindowInsets(pad) -> imePadding() -> horizontal padding -> verticalScroll. Without consumeWindowInsets, imePadding double-counts the nav-bar inset on Android 11+ and the bottom field gets shoved too far up when the keyboard opens. --- .../amethyst/ui/actions/MediaSaverToDisk.kt | 8 ++++++ .../loggedIn/music/NewMusicTrackScreen.kt | 28 ++++++++----------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index fbfe4e3216..125677c0c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -213,6 +213,14 @@ object MediaSaverToDisk { MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES } + cleanMimeType.startsWith("audio/", ignoreCase = true) -> { + // Audio content goes into the Music MediaStore + folder. Routing it through + // Video.EXTERNAL_CONTENT_URI (the previous fall-through behavior) crashes + // with IllegalArgumentException because MediaProvider rejects audio/* into + // the Video collection. + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_MUSIC + } + cleanMimeType.equals(PDF_MIME_TYPE, ignoreCase = true) -> { MediaStore.Downloads.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_DOWNLOADS } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt index 6a3aa9a0c7..870e4deb70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt @@ -140,16 +140,18 @@ fun NewMusicTrackScreen( ) }, ) { 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( - start = 16.dp, - end = 16.dp, - top = pad.calculateTopPadding(), - bottom = pad.calculateBottomPadding(), - ).consumeWindowInsets(pad) + .padding(pad) + .consumeWindowInsets(pad) .imePadding() + .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -201,16 +203,10 @@ fun NewMusicTrackScreen( isError = vm.audioUrl.value.isBlank() && vm.audioMedia.value == null, ) - // Cover URL — same logic as audio: filled by the cover uploader, but a pasted - // URL is fine too. Optional (no isError) because tracks can ship without art. - OutlinedTextField( - value = vm.coverUrl.value, - onValueChange = { vm.coverUrl.value = it }, - label = { Text(stringRes(R.string.music_track_image_url_label)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - ) + // No cover-URL text field: the upload tile at the top of the screen is the + // single source of truth for the cover image. When editing an existing track + // that carries an `image` URL the ViewModel still passes it through to + // MusicTrackEvent.edit, so we don't drop the cover on save. OutlinedTextField( value = vm.album.value, From 3082095a4b9993746152ff12a595c88896920a52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 19:18:41 +0000 Subject: [PATCH 26/27] feat(music): auto-fill metadata from picked audio + drop audio URL field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five changes: 1. Drop the audio-URL text field from the composer. Upload tile at the top is the single source of truth; the ViewModel still threads the existing url through MusicTrackEvent.edit so edit mode keeps it. 2. Auto-fill title / artist / album / duration from MediaMetadataRetriever when an audio file is picked. Only fills empty fields — anything the user already typed wins. Duration is the exception: derived numbers overwrite, since they're not opinions. 3. Rename the SavingTopBar 'Save' button to 'Send' for this screen (introduces SendingTopBar + R.string.send so other screens can reuse). 4. Fix silent data corruption in AddToMusicPlaylistViewModel.init: the previous `if account already set, return` guard kept the FIRST track address the VM ever saw. Subsequent invocations with a different trackAddress (process recreation, navigation reusing the back-stack entry) routed toggle() at the stale track, adding the wrong track to playlists. Now refreshes trackAddress on every call and restarts the scan job when it changes. 5. Inset modifier order on NewMusicTrackScreen tightened in the prior commit, kept here for continuity. --- .../ui/navigation/topbars/ActionTopBar.kt | 17 ++++ .../music/AddToMusicPlaylistViewModel.kt | 20 +++-- .../loggedIn/music/NewMusicTrackScreen.kt | 32 +++----- .../loggedIn/music/NewMusicTrackViewModel.kt | 79 +++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 113 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt index a49514dc17..84b19c084a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -114,6 +114,23 @@ fun SavingTopBar( additionalActions = additionalActions, ) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SendingTopBar( + titleRes: Int? = null, + isActive: () -> Boolean = { true }, + onCancel: () -> Unit, + onPost: () -> Unit, + additionalActions: @Composable (() -> Unit)? = null, +) = ActionTopBar( + titleRes = titleRes, + postRes = R.string.send, + isActive = isActive, + onCancel = onCancel, + onPost = onPost, + additionalActions = additionalActions, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreatingTopBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt index e3d251a29f..56e59c17a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistViewModel.kt @@ -73,14 +73,22 @@ class AddToMusicPlaylistViewModel : ViewModel() { accountViewModel: AccountViewModel, trackAddressTag: String, ) { - if (::account.isInitialized) return + // 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 = AddressSerializer.parse(trackAddressTag) + this.trackAddress = parsed - // Both the initial scan and the live-event re-scan walk LocalCache.addressables, which - // is in-memory but still wants to be off the composition thread. The live scan also - // debounces by checking the bundle for our kind first so we don't re-walk on every - // unrelated event (text notes, reactions, etc). + // (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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt index 870e4deb70..7ce18d2b26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt @@ -77,7 +77,7 @@ 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.SavingTopBar +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 @@ -117,7 +117,7 @@ fun NewMusicTrackScreen( AudioFileSelect( onAudioPicked = { picked -> wantsToPickAudio = false - vm.setPickedAudio(picked) + vm.setPickedAudio(context, picked) }, ) } @@ -126,11 +126,11 @@ fun NewMusicTrackScreen( Scaffold( topBar = { - SavingTopBar( + SendingTopBar( titleRes = R.string.new_music_track, onCancel = { nav.popBack() }, onPost = { - if (!vm.isValid() || isBusy) return@SavingTopBar + if (!vm.isValid() || isBusy) return@SendingTopBar vm.saveAndPublish( context = context, onSuccess = { nav.popBack() }, @@ -188,25 +188,11 @@ fun NewMusicTrackScreen( isError = vm.artist.value.isBlank(), ) - // Audio URL — pre-filled by the audio file uploader, also accepts pasted URLs - // (Wavlake, Stemstr, etc.) when the user doesn't have a local file to upload. - OutlinedTextField( - value = vm.audioUrl.value, - onValueChange = { vm.audioUrl.value = it }, - label = { Text(stringRes(R.string.music_track_audio_url_label)) }, - placeholder = { Text(stringRes(R.string.music_track_audio_url_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - // Either an URL or a picked file is enough to satisfy the gate, so only flag - // the field as an error when both are missing. - isError = vm.audioUrl.value.isBlank() && vm.audioMedia.value == null, - ) - - // No cover-URL text field: the upload tile at the top of the screen is the - // single source of truth for the cover image. When editing an existing track - // that carries an `image` URL the ViewModel still passes it through to - // MusicTrackEvent.edit, so we don't drop the cover on save. + // 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt index 01ad82acb4..63bdc8cd59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt @@ -21,6 +21,8 @@ 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 @@ -130,19 +132,82 @@ class NewMusicTrackViewModel : ViewModel() { coverMedia.value = null } - fun setPickedAudio(uri: SelectedMedia?) { + fun setPickedAudio( + context: Context, + uri: SelectedMedia?, + ) { if (uri == null) { audioMedia.value = null pickedAudioName.value = null - } else { - 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('/') + 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() = setPickedAudio(null) + 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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0a6bfa2ef9..7ff579da98 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -142,6 +142,7 @@ Write a message… Post Save + Send Create Rename Cancel From 8a55bba1f28866928a5e474decc9db74830c1719 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 19:39:50 +0000 Subject: [PATCH 27/27] feat(music): parallel uploads + survive-screen-leave + visible progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues with the send flow on the new-music-track screen: 1. Cover + audio uploaded sequentially. As cover finished, its picker reverted to the placeholder while audio was still going, making the user think the operation was done. 2. The coroutine ran on the screen's viewModelScope, so leaving the screen cancelled it. Rework the upload pipeline: * Snapshot the form into an immutable SendSnapshot before launching, so user edits to the form during the in-flight upload don't poison what gets published, and the coroutine sees stable inputs even after the per-screen VM is cleared. * Run cover + audio uploads in parallel via async/awaitAll. Either side failing throws an UploadException that the outer catch routes to the global toast manager. * Launch through accountViewModel.launchSigner so the work runs on the AccountViewModel.viewModelScope — survives screen leave. Errors and success both surface as global toasts. * Keep coverMedia/audioMedia/pickedAudioName populated through the ENTIRE upload+publish, not just per-phase. Only clear them on success, so each picker keeps showing the user the file it's still uploading. * Single isSending flag replaces the separate isUploading/isPublishing booleans; drives an inline progress banner + disables the Send button + dims the pickers to read-only while in flight. * One-shot completionEvents SharedFlow that the screen subscribes to. If the user is still on the screen when the operation finishes, we popBack; if they've already left, no one is listening and we don't pop someone else's stack frame. Also tighten the playback Row height in MusicTrack from 100dp to 80dp. With a 75dp play button the previous 100dp container left a ~12.5dp empty band top + bottom that the user flagged as too tall. --- .../amethyst/ui/note/types/MusicTrack.kt | 7 +- .../loggedIn/music/NewMusicTrackScreen.kt | 82 ++++- .../loggedIn/music/NewMusicTrackViewModel.kt | 325 +++++++++++------- amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 278 insertions(+), 137 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt index ffe8bf1945..3b20bba162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -215,7 +215,12 @@ fun MusicTrackHeader( } Row( - Modifier.fillMaxWidth().height(100.dp), + // 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt index 7ce18d2b26..576e8fa19e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt @@ -23,6 +23,7 @@ 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 @@ -43,6 +44,7 @@ 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 @@ -59,6 +61,7 @@ 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 @@ -122,19 +125,28 @@ fun NewMusicTrackScreen( ) } - val isBusy = vm.isUploading.value || vm.isPublishing.value + 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, - onSuccess = { nav.popBack() }, - onError = accountViewModel.toastManager::toast, + accountViewModel = accountViewModel, ) }, ) @@ -155,15 +167,25 @@ fun NewMusicTrackScreen( .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( @@ -237,14 +259,18 @@ 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. - Box(modifier = Modifier.clickable(onClick = onPick)) { + // 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 = { vm.clearPickedCover() }, + onDelete = { if (enabled) vm.clearPickedCover() }, accountViewModel = accountViewModel, ) } @@ -254,6 +280,7 @@ private fun CoverImagePicker( ctaRes = R.string.music_track_cover_upload_cta, hintRes = R.string.music_track_cover_upload_hint, onClick = onPick, + enabled = enabled, ) } } @@ -262,6 +289,7 @@ private fun CoverImagePicker( private fun AudioFilePicker( vm: NewMusicTrackViewModel, onPick: () -> Unit, + enabled: Boolean, ) { val pickedName = vm.pickedAudioName.value if (pickedName != null) { @@ -273,7 +301,7 @@ private fun AudioFilePicker( width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = RoundedCornerShape(12.dp), - ).clickable(onClick = onPick) + ).let { if (enabled) it.clickable(onClick = onPick) else it } .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -296,8 +324,10 @@ private fun AudioFilePicker( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - TextButton(onClick = { vm.clearPickedAudio() }) { - Text(stringRes(R.string.cancel)) + if (enabled) { + TextButton(onClick = { vm.clearPickedAudio() }) { + Text(stringRes(R.string.cancel)) + } } } } else { @@ -307,6 +337,39 @@ private fun AudioFilePicker( 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, ) } } @@ -323,6 +386,7 @@ private fun UploadPlaceholder( hintRes: Int, onClick: () -> Unit, aspectRatio: Float? = 1f, + enabled: Boolean = true, ) { val baseModifier = Modifier @@ -331,7 +395,7 @@ private fun UploadPlaceholder( width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = RoundedCornerShape(12.dp), - ).clickable(onClick = onClick) + ).let { if (enabled) it.clickable(onClick = onClick) else it } .padding(24.dp) val finalModifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt index 63bdc8cd59..1fea16f28b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackViewModel.kt @@ -28,6 +28,7 @@ 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 @@ -40,6 +41,12 @@ 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 @@ -66,9 +73,27 @@ class NewMusicTrackViewModel : ViewModel() { val album = mutableStateOf("") val durationSeconds = mutableStateOf("") val description = mutableStateOf("") - val isPublishing = mutableStateOf(false) - val isUploading = mutableStateOf(false) - val uploadError = mutableStateOf(null) + + /** + * Single in-flight flag covering both the parallel uploads and the subsequent publish. + * Drives the Send button's spinner, gates double-tap, and overlays each picker with a + * progress indicator. Kept as one boolean rather than separate upload/publish flags + * because the user experience is "one Send action" — the two phases blur into each + * other and showing them as separate states only confuses the picture. + */ + val isSending = mutableStateOf(false) + + /** + * One-shot success channel. Emits exactly once when an upload+publish round succeeds; + * the screen collects it and pops back. We can't rely on a callback because the + * coroutine runs on [AccountViewModel.viewModelScope] (so the work survives screen + * dismissal), but the screen's nav handle becomes stale the moment the user leaves. + * Tying the popBack to a flow the screen subscribes to keeps it correct: if the user + * has already left, no one is listening and nothing pops; if they're still on screen, + * the dismissal fires. + */ + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() /** * Two separate orchestrators because the cover image and the audio file go through @@ -219,124 +244,178 @@ class NewMusicTrackViewModel : ViewModel() { (audioMedia.value != null || audioUrl.value.isNotBlank()) /** - * Upload any picked files (cover + audio), then sign and broadcast the event with the - * resulting URLs. Returns true on success. The `Save` button on the screen is gated on - * [isValid] AND [isUploading] / [isPublishing], so the user can't double-trigger this. + * 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, - onSuccess: () -> Unit, - onError: (String, String) -> Unit, + accountViewModel: AccountViewModel, ) { - viewModelScope.launch(Dispatchers.IO) { - try { - setUploading(true) - uploadError.value = null + if (isSending.value) return // double-tap guard - if (!performUploads(context, onError)) { - setUploading(false) - return@launch + // 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 } - setUploading(false) - setPublishing(true) - publishEvent() - withContext(Dispatchers.Main.immediate) { onSuccess() } + 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) { - // Surface the failure on the toast layer so the user sees what happened - // instead of silently sitting on a Save button that did nothing. - onError("Failed to save music track", t.message ?: t.javaClass.simpleName) + accountViewModel.toastManager.toast( + "Failed to send music track", + t.message ?: t.javaClass.simpleName, + ) } finally { - setUploading(false) - setPublishing(false) + withContext(Dispatchers.Main.immediate) { isSending.value = false } } } } /** - * Runs the cover and audio uploads in sequence (not parallel — the audio path is much - * heavier and uploading them concurrently double-spikes RAM on the device). On success, - * the resulting URLs are written back into [coverUrl] / [audioUrl] so a retry after - * [publishEvent] failure doesn't re-upload the same files. + * 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 suspend fun performUploads( - context: Context, - onError: (String, String) -> Unit, - ): Boolean { - val acc = account - val server = - selectedServer.value ?: return run { - onError("No upload server selected", "Pick a media server in settings before uploading.") - false - } - val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value) + 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, + ) - coverMedia.value?.let { orch -> - val res = - orch.upload( - alt = title.value.ifBlank { null }, - contentWarningReason = null, - mediaQuality = quality, - server = server, - account = acc, - context = context, - useH265 = false, - stripMetadata = stripMetadata.value, - onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + /** + * Uploads the cover and audio in parallel via `async`/`awaitAll`. Each side is + * independent: if cover fails, audio still completes (and vice versa); the first + * failure throws and propagates out through the catch in [saveAndPublish]. + * + * Returns `(coverUrl?, audioUrl?)`. A null means "no orchestrator was provided for + * that slot" (the caller falls back to the existing URL from the snapshot). + */ + private suspend fun performParallelUploads( + snapshot: SendSnapshot, + accountViewModel: AccountViewModel, + ): Pair = + coroutineScope { + val deferreds = + listOf( + async { + snapshot.coverOrchestrator?.let { runSingleUpload(it, snapshot, "cover") } + }, + async { + snapshot.audioOrchestrator?.let { runSingleUpload(it, snapshot, "audio") } + }, ) - if (!res.allGood) { - onError("Cover upload failed", formatUploadErrors(res.errors, context)) - return false - } - val url = - firstUploadedUrl(res.successful) ?: return run { - onError("Cover upload failed", "Server didn't return a URL for the uploaded cover.") - false - } - withContext(Dispatchers.Main.immediate) { - coverUrl.value = url - coverMedia.value = null - } + val results = deferreds.awaitAll() + results[0] to results[1] } - audioMedia.value?.let { orch -> - val res = - orch.upload( - alt = title.value.ifBlank { null }, - contentWarningReason = null, - // Audio files aren't re-encoded by MediaCompressor (no codec path for - // them), so the quality slider is effectively no-op here; pass it through - // anyway for symmetry. - mediaQuality = quality, - server = server, - account = acc, - context = context, - useH265 = false, - stripMetadata = stripMetadata.value, - onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, - ) - if (!res.allGood) { - onError("Audio upload failed", formatUploadErrors(res.errors, context)) - return false - } - val url = - firstUploadedUrl(res.successful) ?: return run { - onError("Audio upload failed", "Server didn't return a URL for the uploaded audio.") - false - } - withContext(Dispatchers.Main.immediate) { - audioUrl.value = url - audioMedia.value = null - pickedAudioName.value = null - } + 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)) } - - // Remember the server choice for next time so the user doesn't have to pick it again. - acc.settings.changeDefaultFileServer(server) - acc.settings.changeStripLocationOnUpload(stripMetadata.value) - return true + return firstUploadedUrl(res.successful) + ?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.") } + private class UploadException( + kind: String, + details: String, + ) : Exception("$kind upload failed: $details") + private fun firstUploadedUrl(successful: List): String? = successful .firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult } @@ -351,37 +430,33 @@ class NewMusicTrackViewModel : ViewModel() { .distinct() .joinToString(".\n") - private suspend fun publishEvent() { - val parsedTitle = title.value.trim() - val parsedArtist = artist.value.trim() - val parsedUrl = audioUrl.value.trim() - val parsedCover = coverUrl.value.trim().ifBlank { null } - val parsedAlbum = album.value.trim().ifBlank { null } - val parsedDuration = durationSeconds.value.trim().toIntOrNull() - val parsedDescription = description.value - - val existing = loadedEvent + private suspend fun publishEvent( + snapshot: SendSnapshot, + coverUrl: String?, + audioUrl: String, + ) { + val existing = snapshot.loadedEvent val template = if (existing != null) { MusicTrackEvent.edit( earlierVersion = existing, - title = parsedTitle, - artist = parsedArtist, - url = parsedUrl, - description = parsedDescription, - image = parsedCover, - album = parsedAlbum, - duration = parsedDuration, + title = snapshot.title, + artist = snapshot.artist, + url = audioUrl, + description = snapshot.description, + image = coverUrl, + album = snapshot.album, + duration = snapshot.duration, ) } else { MusicTrackEvent.build( - title = parsedTitle, - artist = parsedArtist, - url = parsedUrl, - description = parsedDescription, - image = parsedCover, - album = parsedAlbum, - duration = parsedDuration, + title = snapshot.title, + artist = snapshot.artist, + url = audioUrl, + description = snapshot.description, + image = coverUrl, + album = snapshot.album, + duration = snapshot.duration, ) } @@ -393,8 +468,4 @@ class NewMusicTrackViewModel : ViewModel() { account.delete(target, emptySet()) return true } - - private suspend fun setUploading(value: Boolean) = withContext(Dispatchers.Main.immediate) { isUploading.value = value } - - private suspend fun setPublishing(value: Boolean) = withContext(Dispatchers.Main.immediate) { isPublishing.value = value } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7ff579da98..de109dde84 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1553,6 +1553,7 @@ Pick an MP3, WAV, or FLAC. It will be uploaded to your media server on Save. Audio file ready to upload Uploading… + Uploading cover + audio and publishing. This will keep going even if you leave the screen. Upload failed: %1$s