mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+225
@@ -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,
|
||||
|
||||
+40
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+19
-3
@@ -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)
|
||||
}
|
||||
|
||||
+149
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user