From 3ee699c55555ff89b54d105aff8688b542308af5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 20:33:54 +0000 Subject: [PATCH] feat: manage tracks (reorder/remove) inside the playlist editor Add in-playlist track management to the music playlist composer: each track in the working list shows its artwork/title/artist with move-up, move-down and remove controls. The list is seeded from the loaded event when editing and published in its new order on save. Adding new tracks still happens via the per-song "Add to playlist" sheet. - quartz: MusicPlaylistEvent.edit() now takes the ordered track list and resets the playlist's music-track `a` tags to it (preserving any non-track `a` tags, the d tag, custom hashtags and other metadata). Add MusicPlaylistEventEditTest covering reorder, removal, visibility switch, cover/description clearing and tag preservation. - amethyst: NewMusicPlaylistViewModel gains the working track list plus moveTrackUp/moveTrackDown/removeTrackAt; NewMusicPlaylistScreen renders the editable track section; new string resources. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS --- .../loggedIn/music/NewMusicPlaylistScreen.kt | 225 ++++++++++++++++++ .../music/NewMusicPlaylistViewModel.kt | 40 ++++ amethyst/src/main/res/values/strings.xml | 4 + .../music/playlist/MusicPlaylistEvent.kt | 22 +- .../playlist/MusicPlaylistEventEditTest.kt | 149 ++++++++++++ 5 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEventEditTest.kt 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 index cc364595e0..4af705e568 100644 --- 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 @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -29,11 +31,15 @@ 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.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -50,9 +56,13 @@ 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -60,15 +70,20 @@ 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.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage 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.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import kotlinx.collections.immutable.persistentListOf @Composable @@ -193,6 +208,12 @@ fun NewMusicPlaylistScreen( onCheckedChange = { vm.isCollaborative.value = it }, ) + TrackManagementSection( + vm = vm, + accountViewModel = accountViewModel, + enabled = !isBusy, + ) + if (vm.isEditing) { DeleteMusicPlaylistRow( vm = vm, @@ -245,6 +266,210 @@ private fun LabeledSwitchRow( } } +/** + * In-playlist track management: lists the working track order with reorder (up/down) and remove + * controls. The list is empty in create mode (the FAB makes a fresh playlist) and is seeded from + * the loaded event when editing. Adding *new* tracks happens through the per-song "Add to playlist" + * sheet, not here. + */ +@Composable +private fun TrackManagementSection( + vm: NewMusicPlaylistViewModel, + accountViewModel: AccountViewModel, + enabled: Boolean, +) { + val tracks = vm.tracks.value + + HorizontalDivider() + + val count = tracks.size + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count, count, count), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + if (tracks.isEmpty()) { + Text( + text = stringRes(R.string.music_playlist_no_tracks_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + tracks.forEachIndexed { index, address -> + LoadAddressableNote(address, accountViewModel) { trackNote -> + EditableTrackRow( + position = index + 1, + trackNote = trackNote, + isFirst = index == 0, + isLast = index == tracks.lastIndex, + enabled = enabled, + onMoveUp = { vm.moveTrackUp(index) }, + onMoveDown = { vm.moveTrackDown(index) }, + onRemove = { vm.removeTrackAt(index) }, + accountViewModel = accountViewModel, + ) + } + } +} + +@Composable +private fun EditableTrackRow( + position: Int, + trackNote: AddressableNote?, + isFirst: Boolean, + isLast: Boolean, + enabled: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, +) { + // The note may still be resolving (only the address is known). Keep the reorder/remove controls + // live in that case — they act on the list position, which is valid regardless of whether the + // track metadata has loaded — and show a "Loading track…" placeholder for the title. + if (trackNote == null) { + TrackRow( + position = position, + title = stringRes(R.string.music_playlist_loading_track), + artist = null, + cover = null, + isFirst = isFirst, + isLast = isLast, + enabled = enabled, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + onRemove = onRemove, + accountViewModel = accountViewModel, + ) + return + } + + // Observe so the row fills in (and recomposes) when the track event arrives from a relay or a + // newer revision replaces the cached one. + val trackEvent by observeNoteEvent(trackNote, accountViewModel) + TrackRow( + position = position, + title = trackEvent?.title() ?: stringRes(R.string.music_playlist_unknown_track), + artist = trackEvent?.artist(), + cover = trackEvent?.image(), + isFirst = isFirst, + isLast = isLast, + enabled = enabled, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + onRemove = onRemove, + accountViewModel = accountViewModel, + ) +} + +@Composable +private fun TrackRow( + position: Int, + title: String, + artist: String?, + cover: String?, + isFirst: Boolean, + isLast: Boolean, + enabled: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(24.dp), + ) + + if (cover != null) { + MyAsyncImage( + imageUrl = cover, + contentDescription = null, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + loadedImageModifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + accountViewModel = accountViewModel, + onLoadingBackground = { TrackArtworkPlaceholder() }, + onError = { TrackArtworkPlaceholder() }, + ) + } else { + TrackArtworkPlaceholder() + } + + Spacer(Modifier.size(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + artist?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + IconButton(onClick = onMoveUp, enabled = enabled && !isFirst) { + Icon( + symbol = MaterialSymbols.ArrowUpward, + contentDescription = stringRes(R.string.music_playlist_move_track_up), + modifier = Modifier.size(20.dp), + ) + } + IconButton(onClick = onMoveDown, enabled = enabled && !isLast) { + Icon( + symbol = MaterialSymbols.ArrowDownward, + contentDescription = stringRes(R.string.music_playlist_move_track_down), + modifier = Modifier.size(20.dp), + ) + } + IconButton(onClick = onRemove, enabled = enabled) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.music_playlist_remove_track), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun TrackArtworkPlaceholder() { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } +} + @Composable private fun DeleteMusicPlaylistRow( vm: NewMusicPlaylistViewModel, 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 index 71913afcb1..bbbaaf04c2 100644 --- 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 @@ -69,6 +69,14 @@ class NewMusicPlaylistViewModel : ViewModel() { val isPrivate = mutableStateOf(false) val isCollaborative = mutableStateOf(false) + /** + * Working, ordered list of track addresses the editor mutates (reorder / remove). Seeded from + * the loaded event in edit mode; published in this order on save. Adding *new* tracks still + * happens through the per-song "Add to playlist" sheet — this screen only manages the tracks + * already in the playlist. + */ + val tracks = mutableStateOf>(emptyList()) + /** * 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. @@ -124,6 +132,7 @@ class NewMusicPlaylistViewModel : ViewModel() { coverUrl.value = existing.image().orEmpty() isPrivate.value = existing.isPrivate() isCollaborative.value = existing.isCollaborative() + tracks.value = existing.trackAddresses() } // If the lookup fails, dTag stays null and the screen renders as create-mode. } @@ -139,6 +148,33 @@ class NewMusicPlaylistViewModel : ViewModel() { coverUrl.value = "" } + /** Swap the track at [index] with the one above it. No-op at the top or out of bounds. */ + fun moveTrackUp(index: Int) { + val current = tracks.value + if (index <= 0 || index >= current.size) return + tracks.value = + current.toMutableList().apply { + add(index - 1, removeAt(index)) + } + } + + /** Swap the track at [index] with the one below it. No-op at the bottom or out of bounds. */ + fun moveTrackDown(index: Int) { + val current = tracks.value + if (index < 0 || index >= current.size - 1) return + tracks.value = + current.toMutableList().apply { + add(index + 1, removeAt(index)) + } + } + + /** Drop the track at [index] from the working list. No-op when out of bounds. */ + fun removeTrackAt(index: Int) { + val current = tracks.value + if (index < 0 || index >= current.size) return + tracks.value = current.toMutableList().apply { removeAt(index) } + } + /** A title is the only hard requirement; everything else is optional. */ fun isValid(): Boolean = title.value.isNotBlank() @@ -171,6 +207,7 @@ class NewMusicPlaylistViewModel : ViewModel() { title = title.value.trim(), description = description.value.trim().ifBlank { null }, notes = notes.value, + tracks = tracks.value, isPrivate = isPrivate.value, isCollaborative = isCollaborative.value, coverOrchestrator = coverMedia.value, @@ -217,6 +254,7 @@ class NewMusicPlaylistViewModel : ViewModel() { val title: String, val description: String?, val notes: String, + val tracks: List
, val isPrivate: Boolean, val isCollaborative: Boolean, val coverOrchestrator: MultiOrchestrator?, @@ -283,6 +321,7 @@ class NewMusicPlaylistViewModel : ViewModel() { content = snapshot.notes, image = coverUrl, description = snapshot.description, + tracks = snapshot.tracks, isPrivate = snapshot.isPrivate, isCollaborative = snapshot.isCollaborative, ) @@ -292,6 +331,7 @@ class NewMusicPlaylistViewModel : ViewModel() { content = snapshot.notes, image = coverUrl, description = snapshot.description, + tracks = snapshot.tracks, isPrivate = snapshot.isPrivate, isCollaborative = snapshot.isCollaborative, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 984e37982d..5d3cac00bc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1802,6 +1802,10 @@ 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. + No tracks yet. Add tracks from a song\'s menu using \"Add to playlist\". + Move track up + Move track down + Remove track from playlist 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 2d127c17ed..6d9e90ecff 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 @@ -150,9 +150,11 @@ class MusicPlaylistEvent( * 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. + * behind. The track list is reset to [tracks] in the given order — every existing music + * track `a` tag is dropped and the new list re-added, so the editor's reorder/remove edits + * take effect (any non-track `a` tag is preserved). 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, @@ -160,10 +162,18 @@ class MusicPlaylistEvent( content: String, image: String?, description: String?, + tracks: List
, isPrivate: Boolean, isCollaborative: Boolean, createdAt: Long = TimeUtils.now(), ): EventTemplate { + // Preserve any non-track `a` tags (rare / non-spec). We only reset the music-track refs + // so the editor's reorder + remove operations are authoritative for the track list. + val preservedNonTrackATags = + earlierVersion.tags.filter { tag -> + val address = ATag.parseAddress(tag) + address != null && address.kind != MusicTrackEvent.KIND + } val newTags = earlierVersion.tags.builder { title(title) @@ -178,6 +188,12 @@ class MusicPlaylistEvent( if (isPrivate) private(true) else public(true) if (isCollaborative) collaborative(true) else remove(CollaborativeTag.TAG_NAME) + + // Reset the track list to the editor's working order: drop every `a` tag, then + // re-add the preserved non-track refs followed by the tracks in their new order. + remove(ATag.TAG_NAME) + preservedNonTrackATags.forEach { add(it) } + tracks.forEach { trackAddress(it) } } return EventTemplate(createdAt, KIND, newTags, content) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEventEditTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEventEditTest.kt new file mode 100644 index 0000000000..57578af10a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEventEditTest.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.playlist + +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MusicPlaylistEventEditTest { + private val pubkey = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799" + + private val trackA = Address(MusicTrackEvent.KIND, pubkey, "track-a") + private val trackB = Address(MusicTrackEvent.KIND, pubkey, "track-b") + private val trackC = Address(MusicTrackEvent.KIND, pubkey, "track-c") + + // An `a` tag that does NOT point at a music track (e.g. a curated article). Must survive edit. + private val articleRef = Address(30023, pubkey, "some-article") + + private fun earlierVersion(): MusicPlaylistEvent = + MusicPlaylistEvent( + id = "00", + pubKey = pubkey, + createdAt = 0L, + tags = + arrayOf( + arrayOf("d", "pl1"), + arrayOf("title", "Old Title"), + arrayOf("t", "playlist"), + arrayOf("t", "custom-genre"), + arrayOf("image", "https://old.example/cover.jpg"), + arrayOf("description", "old desc"), + arrayOf("a", "${MusicTrackEvent.KIND}:$pubkey:track-a"), + arrayOf("a", "30023:$pubkey:some-article"), + arrayOf("a", "${MusicTrackEvent.KIND}:$pubkey:track-b"), + arrayOf("a", "${MusicTrackEvent.KIND}:$pubkey:track-c"), + arrayOf("public", "true"), + ), + content = "old notes", + sig = "00", + ) + + private fun resultOf( + tracks: List
, + isPrivate: Boolean = false, + isCollaborative: Boolean = false, + ): MusicPlaylistEvent { + val template = + MusicPlaylistEvent.edit( + earlierVersion = earlierVersion(), + title = "New Title", + content = "new notes", + image = "https://new.example/cover.jpg", + description = "new desc", + tracks = tracks, + isPrivate = isPrivate, + isCollaborative = isCollaborative, + ) + return MusicPlaylistEvent("00", pubkey, 0L, template.tags, template.content, "00") + } + + @Test + fun reordersTracksToTheGivenOrder() { + val result = resultOf(tracks = listOf(trackC, trackA, trackB)) + assertEquals(listOf(trackC, trackA, trackB), result.trackAddresses()) + } + + @Test + fun removesDroppedTracks() { + val result = resultOf(tracks = listOf(trackA, trackC)) + assertEquals(listOf(trackA, trackC), result.trackAddresses()) + } + + @Test + fun updatesComposerOwnedMetadata() { + val result = resultOf(tracks = listOf(trackA), isPrivate = true, isCollaborative = true) + assertEquals("New Title", result.title()) + assertEquals("https://new.example/cover.jpg", result.image()) + assertEquals("new desc", result.description()) + assertEquals("new notes", result.content) + assertTrue(result.isPrivate()) + assertTrue(result.isCollaborative()) + } + + @Test + fun switchingToPrivateClearsThePublicFlag() { + val result = resultOf(tracks = listOf(trackA), isPrivate = true) + val tagsByName = result.tags.groupBy { it[0] } + assertFalse(tagsByName.containsKey("public"), "stale public flag must be dropped") + assertTrue(tagsByName.containsKey("private")) + assertTrue(result.isPrivate()) + } + + @Test + fun preservesDTagCustomHashtagsAndNonTrackReferences() { + val result = resultOf(tracks = listOf(trackA, trackB, trackC)) + val tagsByName = result.tags.groupBy { it[0] } + + // Same addressable identity → same d tag. + assertEquals("pl1", tagsByName["d"]!!.single()[1]) + + // Custom hashtags untouched (both the category marker and the user's genre tag). + val tValues = tagsByName["t"]!!.map { it[1] } + assertTrue(tValues.contains("playlist")) + assertTrue(tValues.contains("custom-genre")) + + // The non-track `a` reference survives the track reset. + val aValues = tagsByName["a"]!!.map { it[1] } + assertTrue(aValues.contains("30023:$pubkey:some-article")) + } + + @Test + fun clearingCoverRemovesTheImageTag() { + val template = + MusicPlaylistEvent.edit( + earlierVersion = earlierVersion(), + title = "New Title", + content = "", + image = null, + description = null, + tracks = listOf(trackA), + isPrivate = false, + isCollaborative = false, + ) + val tagsByName = template.tags.groupBy { it[0] } + assertFalse(tagsByName.containsKey("image"), "null cover must remove the image tag") + assertFalse(tagsByName.containsKey("description"), "null description must remove the tag") + } +}