From af8fffb9890744895a141c52f510be870ed76e94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 19:43:34 +0000 Subject: [PATCH] feat: full-screen music playlist composer with cover image and metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the name-only "new playlist" dialog with a full-screen create/edit composer reachable via a dedicated route (Route.NewMusicPlaylist). The composer surfaces a cover-image upload (same Blossom/NIP-96 pipeline as the music-track composer), plus title, short description, long-form notes, a public/private toggle and a collaborative toggle. An edit affordance now appears on the user's own playlist cards. - quartz: add MusicPlaylistEvent.edit() — updates the composer-owned metadata while preserving the track `a` tags and every other tag of the prior version. - amethyst: NewMusicPlaylistViewModel + NewMusicPlaylistScreen (create/edit/ delete); extract the shared cover-picker/placeholder/progress-banner into MusicComposerUploadUi so the track composer reuses them; FAB now navigates to the route; add edit icon on owned playlist cards; new string resources. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../amethyst/ui/note/types/MusicPlaylist.kt | 22 +- .../loggedIn/music/MusicComposerUploadUi.kt | 172 ++++++++++ .../loggedIn/music/MusicPlaylistsScreen.kt | 2 +- .../loggedIn/music/NewMusicPlaylistFab.kt | 88 +---- .../loggedIn/music/NewMusicPlaylistScreen.kt | 301 +++++++++++++++++ .../music/NewMusicPlaylistViewModel.kt | 308 ++++++++++++++++++ .../loggedIn/music/NewMusicTrackScreen.kt | 136 +------- amethyst/src/main/res/values/strings.xml | 12 + .../music/playlist/MusicPlaylistEvent.kt | 52 +++ 11 files changed, 889 insertions(+), 211 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicComposerUploadUi.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistViewModel.kt 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 efaf02b972..78ecbf362d 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 @@ -141,6 +141,7 @@ 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.NewMusicPlaylistScreen 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 @@ -312,6 +313,7 @@ fun BuildNavigation( composableFromEnd { PodcastsScreen(accountViewModel, nav) } composableFromEndArgs { PodcastScreen(it.pubkey, accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } + composableFromEndArgs { NewMusicPlaylistScreen(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/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 53fcb6cb15..de8f44988f 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 @@ -175,6 +175,11 @@ sealed class Route { val dTag: String? = null, ) : Route() + @Serializable + data class NewMusicPlaylist( + val dTag: String? = null, + ) : Route() + @Serializable data class AddToMusicPlaylist( val trackAddress: String, 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 1cea089e40..29ccadab7a 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 @@ -146,6 +146,9 @@ fun MusicPlaylistHeader( val trackAddresses = remember(noteEvent) { noteEvent.trackAddresses() } val isCollaborative = remember(noteEvent) { noteEvent.isCollaborative() } val isPrivate = remember(noteEvent) { noteEvent.isPrivate() } + // The composer can only resolve and edit a playlist the logged-in user authored (it loads the + // addressable from their own pubkey), so only surface the edit affordance for owned playlists. + val isOwnPlaylist = remember(note) { accountViewModel.isLoggedUser(note.author) } Column(MaterialTheme.colorScheme.replyModifier) { MusicPlaylistCover(image, note, trackAddresses.size, accountViewModel) @@ -168,7 +171,10 @@ fun MusicPlaylistHeader( ) } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { Icon( symbol = MaterialSymbols.AutoMirrored.PlaylistAdd, contentDescription = null, @@ -188,6 +194,20 @@ fun MusicPlaylistHeader( if (isPrivate) { PlaylistTag(text = stringRes(R.string.music_playlist_private)) } + if (isOwnPlaylist) { + Spacer(Modifier.weight(1f)) + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = stringRes(R.string.music_playlist_edit_action), + tint = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clip(CircleShape) + .clickable { nav.nav(Route.NewMusicPlaylist(dTag = noteEvent.dTag())) } + .padding(4.dp) + .size(20.dp), + ) + } } shortDescription?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicComposerUploadUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicComposerUploadUi.kt new file mode 100644 index 0000000000..492e294fb5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicComposerUploadUi.kt @@ -0,0 +1,172 @@ +/* + * 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.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +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.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Square cover-image picker shared by the music track and playlist composers. When a file is + * picked it shows the upload preview (tap to swap, close button to remove); otherwise it shows the + * dashed upload placeholder. While an upload is in flight (`enabled = false`) the tap/delete + * gestures are dropped so the user can't mutate the selection mid-upload, but the preview stays + * visible so they can see what's being sent. + */ +@Composable +fun CoverImagePicker( + cover: MultiOrchestrator?, + onPick: () -> Unit, + onDelete: () -> Unit, + accountViewModel: AccountViewModel, + enabled: Boolean, + ctaRes: Int, + hintRes: Int, +) { + if (cover != null) { + Box(modifier = if (enabled) Modifier.clickable(onClick = onPick) else Modifier) { + ShowImageUploadGallery( + list = cover, + onDelete = { if (enabled) onDelete() }, + accountViewModel = accountViewModel, + ) + } + } else { + UploadPlaceholder( + iconSymbol = MaterialSymbols.AddPhotoAlternate, + ctaRes = ctaRes, + hintRes = hintRes, + onClick = onPick, + enabled = enabled, + ) + } +} + +/** + * Shared upload-placeholder card. The cover pickers use a 1:1 aspect ratio (square upload tile); + * the audio picker leaves [aspectRatio] null so the row hugs the icon + text content. + */ +@Composable +fun UploadPlaceholder( + iconSymbol: MaterialSymbol, + ctaRes: Int, + hintRes: Int, + onClick: () -> Unit, + aspectRatio: Float? = 1f, + enabled: Boolean = true, +) { + val baseModifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).let { if (enabled) it.clickable(onClick = onClick) else it } + .padding(24.dp) + + val finalModifier = + if (aspectRatio != null) baseModifier.aspectRatio(aspectRatio) else baseModifier + + Box( + modifier = finalModifier, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + symbol = iconSymbol, + contentDescription = null, + modifier = Modifier.size(if (aspectRatio != null) 56.dp else 36.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Box(modifier = Modifier.height(12.dp)) + Text( + text = stringRes(ctaRes), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Box(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(hintRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +/** + * Banner shown at the top of a composer form while the send coroutine is in flight. The coroutine + * runs on `accountViewModel.viewModelScope` so it survives the screen; this banner is the user's + * primary feedback that something IS happening. + */ +@Composable +fun UploadInProgressBanner(messageRes: Int) { + 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, + ) + Box(modifier = Modifier.size(12.dp)) + Text( + text = stringRes(messageRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} 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 399faca3ce..e12a3821a4 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 @@ -88,7 +88,7 @@ fun MusicPlaylistsScreen( }, floatingButton = { FabBottomBarPadded(nav) { - NewMusicPlaylistFab(accountViewModel) + NewMusicPlaylistFab(nav) } }, accountViewModel = accountViewModel, 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 89e7ddb55b..65b4d39f78 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 @@ -20,43 +20,30 @@ */ 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.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable -fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) { - var dialogOpen by rememberSaveable { mutableStateOf(false) } - +fun NewMusicPlaylistFab(nav: INav) { FloatingActionButton( - onClick = { dialogOpen = true }, + // Opens the full-screen playlist composer in create mode (no d-tag). The composer + // owns title, cover-image upload, description, notes, visibility and the collaborative + // flag — the old name-only dialog couldn't surface any of those. + onClick = { nav.nav(Route.NewMusicPlaylist()) }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, @@ -68,69 +55,12 @@ fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) { tint = Color.White, ) } - - if (dialogOpen) { - NewEmptyPlaylistDialog( - onDismiss = { dialogOpen = false }, - onCreate = { name -> - // Empty playlist: user adds tracks later via the "Add to playlist" sheet on - // any music-track note. `name` is already trimmed by the dialog's confirm - // button, no need to .trim() again here. - accountViewModel.launchSigner { - accountViewModel.account.signAndComputeBroadcast( - MusicPlaylistEvent.build(title = name), - ) - // Compose state writes belong on the main dispatcher — launchSigner is - // Dispatchers.IO, so we hop back before flipping the dialog flag. - withContext(Dispatchers.Main.immediate) { - dialogOpen = false - } - } - }, - ) - } -} - -@Composable -private fun NewEmptyPlaylistDialog( - onDismiss: () -> Unit, - onCreate: (String) -> Unit, -) { - var name by rememberSaveable { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.music_playlist_new_dialog_title)) }, - text = { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - onClick = { onCreate(name.trim()) }, - enabled = name.trim().isNotBlank(), - ) { - Text(stringRes(R.string.music_playlist_create_action)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringRes(R.string.cancel)) - } - }, - ) } @Preview @Composable private fun NewMusicPlaylistFabPreview() { ThemeComparisonRow { - NewMusicPlaylistFab(accountViewModel = mockAccountViewModel()) + NewMusicPlaylistFab(nav = EmptyNav()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistScreen.kt new file mode 100644 index 0000000000..cc364595e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistScreen.kt @@ -0,0 +1,301 @@ +/* + * 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.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.size +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.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +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.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.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun NewMusicPlaylistScreen( + editDTag: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewMusicPlaylistViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(accountViewModel) { + vm.init(accountViewModel, editDTag) + } + + StrippingFailureDialog(vm.strippingFailureConfirmation) + + var wantsToPickCover by remember { mutableStateOf(false) } + if (wantsToPickCover) { + GallerySelectSingle( + onImageUri = { picked -> + wantsToPickCover = false + vm.setPickedCover( + if (picked != null) persistentListOf(picked) else persistentListOf(), + ) + }, + ) + } + + 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. + LaunchedEffect(vm) { + vm.completionEvents.collect { nav.popBack() } + } + + Scaffold( + topBar = { + SendingTopBar( + titleRes = if (vm.isEditing) R.string.edit_music_playlist else R.string.new_music_playlist, + onCancel = { nav.popBack() }, + isActive = { vm.isValid() && !isBusy }, + onPost = { + if (!vm.isValid() || isBusy) return@SendingTopBar + vm.saveAndPublish( + context = context, + accountViewModel = accountViewModel, + ) + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isBusy) UploadInProgressBanner(R.string.music_playlist_uploading_banner) + + CoverImagePicker( + cover = vm.coverMedia.value, + onPick = { wantsToPickCover = true }, + onDelete = { vm.clearPickedCover() }, + accountViewModel = accountViewModel, + enabled = !isBusy, + ctaRes = R.string.music_playlist_cover_upload_cta, + hintRes = R.string.music_playlist_cover_upload_hint, + ) + + OutlinedTextField( + value = vm.title.value, + onValueChange = { vm.title.value = it }, + label = { Text(stringRes(R.string.music_playlist_title_label)) }, + placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + isError = vm.title.value.isBlank(), + ) + + OutlinedTextField( + value = vm.description.value, + onValueChange = { vm.description.value = it }, + label = { Text(stringRes(R.string.music_playlist_description_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + OutlinedTextField( + value = vm.notes.value, + onValueChange = { vm.notes.value = it }, + label = { Text(stringRes(R.string.music_playlist_notes_label)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + LabeledSwitchRow( + iconSymbol = if (vm.isPrivate.value) MaterialSymbols.Lock else MaterialSymbols.Public, + title = stringRes(R.string.music_playlist_private), + subtitle = stringRes(R.string.music_playlist_private_hint), + checked = vm.isPrivate.value, + enabled = !isBusy, + onCheckedChange = { vm.isPrivate.value = it }, + ) + + LabeledSwitchRow( + iconSymbol = MaterialSymbols.Groups, + title = stringRes(R.string.music_playlist_collaborative), + subtitle = stringRes(R.string.music_playlist_collaborative_hint), + checked = vm.isCollaborative.value, + enabled = !isBusy, + onCheckedChange = { vm.isCollaborative.value = it }, + ) + + if (vm.isEditing) { + DeleteMusicPlaylistRow( + vm = vm, + onDeleted = { nav.popBack() }, + accountViewModel = accountViewModel, + ) + } + } + } +} + +@Composable +private fun LabeledSwitchRow( + iconSymbol: MaterialSymbol, + title: String, + subtitle: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = iconSymbol, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.size(12.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + } +} + +@Composable +private fun DeleteMusicPlaylistRow( + vm: NewMusicPlaylistViewModel, + 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_playlist_delete)) + } + + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringRes(R.string.music_playlist_delete)) }, + text = { Text(stringRes(R.string.music_playlist_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + confirming = false + accountViewModel.launchSigner { + if (vm.deleteLoaded()) onDeleted() + } + }) { + Text( + text = stringRes(R.string.music_playlist_delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { confirming = false }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +@Preview +@Composable +private fun NewMusicPlaylistScreenPreview() { + ThemeComparisonColumn { + NewMusicPlaylistScreen( + editDTag = null, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistViewModel.kt new file mode 100644 index 0000000000..71913afcb1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicPlaylistViewModel.kt @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +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.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext + +/** + * Composer for a kind-34139 Music Playlist. Mirrors the [NewMusicTrackViewModel] flow: the user + * picks a cover image up front (Save uploads it through Blossom/NIP-96, then publishes the + * addressable event with the returned URL) and fills the metadata fields. + * + * In edit mode (`editDTag` set AND the event resolves from LocalCache), publishes via + * [MusicPlaylistEvent.edit] so the playlist's track list (`a` tags) and every other tag the + * composer doesn't surface are 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 NewMusicPlaylistViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + + /** Short description, written into the `description` tag. */ + val description = mutableStateOf("") + + /** Long-form notes, written into `event.content` (the JSON `content` field). */ + val notes = mutableStateOf("") + + val coverUrl = mutableStateOf("") + val isPrivate = mutableStateOf(false) + val isCollaborative = mutableStateOf(false) + + /** + * Single in-flight flag covering both the cover upload and the subsequent publish. Drives the + * Send button's spinner, gates double-tap, and overlays the picker with a progress indicator. + */ + val isSending = mutableStateOf(false) + + /** + * One-shot success channel. Emits exactly once when an upload+publish round succeeds; the + * screen collects it and pops back. See [NewMusicTrackViewModel] for why this is a flow rather + * than a callback (the work survives screen dismissal on [AccountViewModel.viewModelScope]). + */ + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val coverMedia = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + + val selectedServer = mutableStateOf(null) + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED — matches Badge's slider semantics. + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + /** Stable d-tag for the addressable: null = create-new, non-null = edit-existing. */ + private var dTag: String? = null + + /** The loaded event in edit mode; needed for `edit()` + NIP-09 deletion. */ + private var loadedEvent: MusicPlaylistEvent? = null + + /** Only `true` once an existing event has been resolved from cache. */ + val isEditing: Boolean + get() = loadedEvent != null + + fun init( + accountViewModel: AccountViewModel, + editDTag: String?, + ) { + if (::account.isInitialized) return // idempotent across recompositions + this.account = accountViewModel.account + this.selectedServer.value = account.settings.defaultFileServer + this.stripMetadata.value = account.settings.stripLocationOnUpload + + if (editDTag != null) { + val existingAddress = Address(MusicPlaylistEvent.KIND, account.userProfile().pubkeyHex, editDTag) + val existingNote = LocalCache.addressables.get(existingAddress) + (existingNote?.event as? MusicPlaylistEvent)?.let { existing -> + dTag = editDTag + loadedEvent = existing + title.value = existing.title().orEmpty() + description.value = existing.description().orEmpty() + notes.value = existing.content + coverUrl.value = existing.image().orEmpty() + isPrivate.value = existing.isPrivate() + isCollaborative.value = existing.isCollaborative() + } + // If the lookup fails, dTag stays null and the screen renders as create-mode. + } + } + + fun setPickedCover(uris: ImmutableList) { + coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun clearPickedCover() { + coverMedia.value = null + // Also drop any previously-published cover so "remove" sticks when editing. + coverUrl.value = "" + } + + /** A title is the only hard requirement; everything else is optional. */ + fun isValid(): Boolean = title.value.isNotBlank() + + /** + * Upload the picked cover (if any), then sign and broadcast the event. 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. + */ + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return // double-tap guard + if (!isValid()) return + + val acc = account + val server = selectedServer.value + if (server == null && coverMedia.value != null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + + // 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. + val snapshot = + SendSnapshot( + title = title.value.trim(), + description = description.value.trim().ifBlank { null }, + notes = notes.value, + isPrivate = isPrivate.value, + isCollaborative = isCollaborative.value, + coverOrchestrator = coverMedia.value, + existingCoverUrl = coverUrl.value.trim().ifBlank { null }, + server = server, + quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value), + stripMetadata = stripMetadata.value, + loadedEvent = loadedEvent, + appContext = context.applicationContext, + ) + + isSending.value = true + + accountViewModel.launchSigner { + try { + val newCoverUrl = + snapshot.coverOrchestrator?.let { runCoverUpload(it, snapshot) } + val finalCoverUrl = newCoverUrl ?: snapshot.existingCoverUrl + + publishEvent(snapshot, finalCoverUrl) + + // Remember the server + strip-metadata choice so the user doesn't have to re-pick + // them on the next playlist or track. + snapshot.server?.let { acc.settings.changeDefaultFileServer(it) } + acc.settings.changeStripLocationOnUpload(snapshot.stripMetadata) + + withContext(Dispatchers.Main.immediate) { + coverMedia.value = null + if (newCoverUrl != null) coverUrl.value = newCoverUrl + } + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast( + "Failed to send playlist", + t.message ?: t.javaClass.simpleName, + ) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } + + private data class SendSnapshot( + val title: String, + val description: String?, + val notes: String, + val isPrivate: Boolean, + val isCollaborative: Boolean, + val coverOrchestrator: MultiOrchestrator?, + val existingCoverUrl: String?, + val server: ServerName?, + val quality: CompressorQuality, + val stripMetadata: Boolean, + val loadedEvent: MusicPlaylistEvent?, + val appContext: Context, + ) + + private suspend fun runCoverUpload( + orch: MultiOrchestrator, + snapshot: SendSnapshot, + ): String { + val server = snapshot.server ?: throw UploadException("No upload server selected.") + val res = + orch.upload( + alt = snapshot.title.ifBlank { null }, + contentWarningReason = null, + mediaQuality = snapshot.quality, + server = server, + account = account, + context = snapshot.appContext, + useH265 = false, + stripMetadata = snapshot.stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + if (!res.allGood) { + throw UploadException(formatUploadErrors(res.errors, snapshot.appContext)) + } + return firstUploadedUrl(res.successful) + ?: throw UploadException("Server didn't return a URL for the uploaded cover.") + } + + private class UploadException( + details: String, + ) : Exception("Cover upload failed: $details") + + private fun firstUploadedUrl(successful: List): String? = + successful + .firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult } + ?.url + + private fun formatUploadErrors( + errors: List, + context: Context, + ): String = + errors + .map { context.getString(it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + + private suspend fun publishEvent( + snapshot: SendSnapshot, + coverUrl: String?, + ) { + val existing = snapshot.loadedEvent + val template = + if (existing != null) { + MusicPlaylistEvent.edit( + earlierVersion = existing, + title = snapshot.title, + content = snapshot.notes, + image = coverUrl, + description = snapshot.description, + isPrivate = snapshot.isPrivate, + isCollaborative = snapshot.isCollaborative, + ) + } else { + MusicPlaylistEvent.build( + title = snapshot.title, + content = snapshot.notes, + image = coverUrl, + description = snapshot.description, + isPrivate = snapshot.isPrivate, + isCollaborative = snapshot.isCollaborative, + ) + } + + account.signAndComputeBroadcast(template) + } + + suspend fun deleteLoaded(): Boolean { + val target = loadedEvent ?: return false + account.delete(target, emptySet()) + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackScreen.kt index 576e8fa19e..bfa05cae50 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,18 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -44,7 +41,6 @@ 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 @@ -61,23 +57,19 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar @@ -173,13 +165,16 @@ fun NewMusicTrackScreen( // 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() + if (isBusy) UploadInProgressBanner(R.string.music_track_uploading_banner) CoverImagePicker( - vm = vm, - accountViewModel = accountViewModel, + cover = vm.coverMedia.value, onPick = { wantsToPickCover = true }, + onDelete = { vm.clearPickedCover() }, + accountViewModel = accountViewModel, enabled = !isBusy, + ctaRes = R.string.music_track_cover_upload_cta, + hintRes = R.string.music_track_cover_upload_hint, ) AudioFilePicker( @@ -254,37 +249,6 @@ fun NewMusicTrackScreen( } } -@Composable -private fun CoverImagePicker( - vm: NewMusicTrackViewModel, - accountViewModel: AccountViewModel, - onPick: () -> Unit, - enabled: Boolean, -) { - val picked = vm.coverMedia.value - if (picked != null) { - // Tap the preview to swap to a different image — same gesture as the Badge composer. - // While the upload is in flight (enabled = false) we drop the tap handler so the - // user can't trigger a re-pick mid-upload; the picker stays visible so they can - // see what's being sent. - Box(modifier = if (enabled) Modifier.clickable(onClick = onPick) else Modifier) { - ShowImageUploadGallery( - list = picked, - onDelete = { if (enabled) vm.clearPickedCover() }, - accountViewModel = accountViewModel, - ) - } - } else { - UploadPlaceholder( - iconSymbol = MaterialSymbols.AddPhotoAlternate, - ctaRes = R.string.music_track_cover_upload_cta, - hintRes = R.string.music_track_cover_upload_hint, - onClick = onPick, - enabled = enabled, - ) - } -} - @Composable private fun AudioFilePicker( vm: NewMusicTrackViewModel, @@ -342,94 +306,6 @@ private fun AudioFilePicker( } } -/** - * Banner shown at the top of the form while the send coroutine is in flight. The - * coroutine runs on `accountViewModel.viewModelScope` so it survives the screen, but - * this banner is the user's primary feedback that something IS happening — without it - * the previous flow looked like a no-op after pressing Send while the cover finished - * uploading and reverted to the picker placeholder mid-flight. - */ -@Composable -private fun UploadInProgressBanner() { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primaryContainer) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - Spacer(modifier = Modifier.size(12.dp)) - Text( - text = stringRes(R.string.music_track_uploading_banner), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } -} - -/** - * Shared upload-placeholder card used by both the cover and the audio pickers. The cover - * uses a 1:1 aspect ratio (square upload tile, matching the Badge composer); the audio - * picker leaves [aspectRatio] null so the row hugs the icon + text content. - */ -@Composable -private fun UploadPlaceholder( - iconSymbol: MaterialSymbol, - ctaRes: Int, - hintRes: Int, - onClick: () -> Unit, - aspectRatio: Float? = 1f, - enabled: Boolean = true, -) { - val baseModifier = - Modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outline, - shape = RoundedCornerShape(12.dp), - ).let { if (enabled) it.clickable(onClick = onClick) else it } - .padding(24.dp) - - val finalModifier = - if (aspectRatio != null) baseModifier.aspectRatio(aspectRatio) else baseModifier - - Box( - modifier = finalModifier, - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - symbol = iconSymbol, - contentDescription = null, - modifier = Modifier.size(if (aspectRatio != null) 56.dp else 36.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringRes(ctaRes), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringRes(hintRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } -} - // Single-file audio picker using OpenDocument restricted to audio MIME types. We don't // reuse the shared FileSelect because that one also accepts application/pdf, which would // let the user pick a PDF and try to publish it as a track — not what this screen is for. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3acb23326d..984e37982d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1790,6 +1790,18 @@ Uploading… Uploading cover + audio and publishing. This will keep going even if you leave the screen. Upload failed: %1$s + Edit playlist + Edit playlist + Title + Description (optional) + Notes (optional) + Upload a cover image + Pick a square image to be the artwork for this playlist. + Hide this playlist from your public profile. + Let others add tracks to this playlist. + Delete this playlist + Publish a NIP-09 deletion for this playlist? + Uploading cover and publishing. This will keep going even if you leave the screen. Loading location 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 b573b72d22..2d127c17ed 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,6 +25,7 @@ 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 @@ -138,6 +139,57 @@ class MusicPlaylistEvent( initializer() } + /** + * Builds a replacement event from an existing one, updating only the metadata fields the + * composer surfaces (`title`, `image`, `description`, the long-form `content`, the + * public/private flag and the `collaborative` flag) while preserving every other tag — + * crucially the `a` track references, plus any extra `t` hashtags or custom metadata the + * composer doesn't expose. + * + * `title` always replaces. The composer owns `image` and `description`, so a null/blank + * value means "remove that tag" rather than "keep whatever was there". Visibility is + * re-asserted from scratch: both `public` and `private` are dropped first, then exactly + * one is re-added, so a public→private switch (or vice versa) never leaves a stale flag + * behind. 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. + */ + fun edit( + earlierVersion: MusicPlaylistEvent, + title: String, + content: String, + image: String?, + description: String?, + isPrivate: Boolean, + isCollaborative: Boolean, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val newTags = + earlierVersion.tags.builder { + title(title) + alt("$ALT_DESCRIPTION_PREFIX: $title") + setOrRemove(image, ImageTag.TAG_NAME, ::image) + setOrRemove(description, DescriptionTag.TAG_NAME, ::description) + + // Re-assert exactly one visibility flag. Drop both first so switching + // public↔private doesn't leave the previous flag lingering on the event. + remove(PublicTag.TAG_NAME) + remove(PrivateTag.TAG_NAME) + if (isPrivate) private(true) else public(true) + + if (isCollaborative) collaborative(true) else remove(CollaborativeTag.TAG_NAME) + } + return EventTemplate(createdAt, KIND, newTags, content) + } + + private fun TagArrayBuilder.setOrRemove( + value: String?, + tagName: String, + setter: (String) -> Unit, + ) { + if (value.isNullOrBlank()) remove(tagName) else setter(value) + } + /** * 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,