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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
This commit is contained in:
Claude
2026-06-17 20:33:54 +00:00
parent af8fffb989
commit 3ee699c555
5 changed files with 437 additions and 3 deletions
@@ -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<MusicTrackEvent>(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,
@@ -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<List<Address>>(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<Address>,
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,
)
+4
View File
@@ -1802,6 +1802,10 @@
<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="music_playlist_no_tracks_hint">No tracks yet. Add tracks from a song\'s menu using \"Add to playlist\".</string>
<string name="music_playlist_move_track_up">Move track up</string>
<string name="music_playlist_move_track_down">Move track down</string>
<string name="music_playlist_remove_track">Remove track from playlist</string>
<string name="loading_location">Loading location</string>
@@ -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<Address>,
isPrivate: Boolean,
isCollaborative: Boolean,
createdAt: Long = TimeUtils.now(),
): EventTemplate<MusicPlaylistEvent> {
// 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<MusicPlaylistEvent> {
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)
}
@@ -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<Address>,
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")
}
}