feat: full-screen music playlist composer with cover image and metadata

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
This commit is contained in:
Claude
2026-06-17 19:43:34 +00:00
parent fa5d126ba7
commit af8fffb989
11 changed files with 889 additions and 211 deletions
@@ -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<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.NewMusicPlaylist> { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
@@ -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,
@@ -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 {
@@ -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,
)
}
}
@@ -88,7 +88,7 @@ fun MusicPlaylistsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewMusicPlaylistFab(accountViewModel)
NewMusicPlaylistFab(nav)
}
},
accountViewModel = accountViewModel,
@@ -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())
}
}
@@ -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(),
)
}
}
@@ -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<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
val coverMedia = mutableStateOf<MultiOrchestrator?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(null)
// 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED — matches Badge's slider semantics.
val mediaQualitySlider = mutableStateOf(1)
val stripMetadata = mutableStateOf(true)
/** Stable d-tag for the addressable: null = create-new, non-null = edit-existing. */
private var dTag: String? = null
/** The loaded event in edit mode; needed for `edit()` + NIP-09 deletion. */
private var loadedEvent: 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<SelectedMedia>) {
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<UploadingState.Finished>): String? =
successful
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
?.url
private fun formatUploadErrors(
errors: List<UploadingState.Error>,
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
}
}
@@ -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.
+12
View File
@@ -1790,6 +1790,18 @@
<string name="music_track_uploading">Uploading…</string>
<string name="music_track_uploading_banner">Uploading cover + audio and publishing. This will keep going even if you leave the screen.</string>
<string name="music_track_upload_failed">Upload failed: %1$s</string>
<string name="edit_music_playlist">Edit playlist</string>
<string name="music_playlist_edit_action">Edit playlist</string>
<string name="music_playlist_title_label">Title</string>
<string name="music_playlist_description_label">Description (optional)</string>
<string name="music_playlist_notes_label">Notes (optional)</string>
<string name="music_playlist_cover_upload_cta">Upload a cover image</string>
<string name="music_playlist_cover_upload_hint">Pick a square image to be the artwork for this playlist.</string>
<string name="music_playlist_private_hint">Hide this playlist from your public profile.</string>
<string name="music_playlist_collaborative_hint">Let others add tracks to this playlist.</string>
<string name="music_playlist_delete">Delete this playlist</string>
<string name="music_playlist_delete_confirm">Publish a NIP-09 deletion for this playlist?</string>
<string name="music_playlist_uploading_banner">Uploading cover and publishing. This will keep going even if you leave the screen.</string>
<string name="loading_location">Loading location</string>
@@ -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<MusicPlaylistEvent> {
val newTags =
earlierVersion.tags.builder<MusicPlaylistEvent> {
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<MusicPlaylistEvent>.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,