Merge pull request #3251 from vitorpamplona/claude/hopeful-cori-qpq0ug

Add music playlist composer screen and edit support
This commit is contained in:
Vitor Pamplona
2026-06-17 17:25:42 -04:00
committed by GitHub
21 changed files with 1542 additions and 225 deletions

View File

@@ -142,6 +142,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
@@ -313,6 +314,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) }

View File

@@ -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,

View File

@@ -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 {

View File

@@ -292,6 +292,24 @@ class TopNavFilterState(
)
}
private val _musicRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
) { peopleLists, interests ->
checkNotInMainThread()
emit(
listOf(
// Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the
// music + playlists screens can show only the user's own published items.
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
peopleLists,
interests,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
@@ -341,6 +359,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
val musicRoutes =
_musicRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}

View File

@@ -0,0 +1,244 @@
/*
* 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.Image
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.CircleShape
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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil3.compose.rememberAsyncImagePainter
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.
*
* Three states, in priority order:
* - a freshly picked local file ([cover]) → upload preview (tap to swap, close button to remove);
* - otherwise an already-published cover ([existingUrl], set when editing) → the remote image with
* the same tap-to-swap / remove affordances, so editing a track/playlist shows its current art;
* - otherwise 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?,
existingUrl: String?,
onPick: () -> Unit,
onDelete: () -> Unit,
accountViewModel: AccountViewModel,
enabled: Boolean,
ctaRes: Int,
hintRes: Int,
) {
when {
cover != null ->
Box(modifier = if (enabled) Modifier.clickable(onClick = onPick) else Modifier) {
ShowImageUploadGallery(
list = cover,
onDelete = { if (enabled) onDelete() },
accountViewModel = accountViewModel,
)
}
!existingUrl.isNullOrBlank() ->
ExistingCoverPreview(
url = existingUrl,
onPick = onPick,
onDelete = onDelete,
enabled = enabled,
)
else ->
UploadPlaceholder(
iconSymbol = MaterialSymbols.AddPhotoAlternate,
ctaRes = ctaRes,
hintRes = hintRes,
onClick = onPick,
enabled = enabled,
)
}
}
/**
* Renders the already-published cover (a remote URL) as a square tile matching the upload preview:
* tap anywhere to pick a replacement, or use the corner button to clear it. Used in edit mode so
* the composer reflects the cover the event already carries instead of showing an empty placeholder.
*/
@Composable
private fun ExistingCoverPreview(
url: String,
onPick: () -> Unit,
onDelete: () -> Unit,
enabled: Boolean,
) {
val shape = RoundedCornerShape(12.dp)
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(shape)
.let { if (enabled) it.clickable(onClick = onPick) else it },
) {
val painter = rememberAsyncImagePainter(model = url)
Image(
painter = painter,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize(),
)
if (enabled) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f))
.clickable(onClick = onDelete)
.padding(4.dp),
) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
}
}
/**
* 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,
)
}
}

View File

@@ -88,7 +88,7 @@ fun MusicPlaylistsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewMusicPlaylistFab(accountViewModel)
NewMusicPlaylistFab(nav)
}
},
accountViewModel = accountViewModel,

View File

@@ -63,8 +63,9 @@ private fun MusicPlaylistsTopNavFilterBar(
onChange: (FeedDefinition) -> Unit,
) {
// Same route catalog as MusicTracks (and Articles / Longs): All Follows, Your Follows,
// kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list.
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
// kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list — plus
// "Mine" for the user's own published playlists.
val allLists by followListsModel.musicRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,

View File

@@ -62,10 +62,10 @@ private fun MusicTracksTopNavFilterBar(
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
// Music is content-style (like Articles / Long-form), so reuse the same route catalog
// as those feeds — All Follows, Your Follows, kind3 Follows, Around Me, Global, custom
// people lists, interest sets, mute list.
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
// Music is content-style (like Articles / Long-form), so reuse that route catalog — All
// Follows, Your Follows, kind3 Follows, Around Me, Global, custom people lists, interest
// sets, mute list — plus "Mine" for the user's own published tracks.
val allLists by followListsModel.musicRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,

View File

@@ -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())
}
}

View File

@@ -0,0 +1,527 @@
/*
* 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.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.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.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
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.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
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
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,
existingUrl = vm.coverUrl.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 },
)
TrackManagementSection(
vm = vm,
accountViewModel = accountViewModel,
enabled = !isBusy,
)
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,
)
}
}
/**
* 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,
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(),
)
}
}

View File

@@ -0,0 +1,348 @@
/*
* 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)
/**
* 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.
*/
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()
tracks.value = existing.trackAddresses()
}
// 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 = ""
}
/** 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()
/**
* 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,
tracks = tracks.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 tracks: List<Address>,
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,
tracks = snapshot.tracks,
isPrivate = snapshot.isPrivate,
isCollaborative = snapshot.isCollaborative,
)
} else {
MusicPlaylistEvent.build(
title = snapshot.title,
content = snapshot.notes,
image = coverUrl,
description = snapshot.description,
tracks = snapshot.tracks,
isPrivate = snapshot.isPrivate,
isCollaborative = snapshot.isCollaborative,
)
}
account.signAndComputeBroadcast(template)
}
suspend fun deleteLoaded(): Boolean {
val target = loadedEvent ?: return false
account.delete(target, emptySet())
return true
}
}

View File

@@ -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,17 @@ 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,
existingUrl = vm.coverUrl.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 +250,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 +307,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.

View File

@@ -155,6 +155,9 @@ class NewMusicTrackViewModel : ViewModel() {
fun clearPickedCover() {
coverMedia.value = null
// Also drop the already-published cover URL so the remove gesture removes the existing
// artwork on save (MusicTrackEvent.edit treats a blank image as "remove the tag").
coverUrl.value = ""
}
fun setPickedAudio(

View File

@@ -54,15 +54,32 @@ class MusicPlaylistsFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it ->
accept(it, params)
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> isMine(it, me) }
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> accept(it, params) }
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
return newItems.filterTo(HashSet()) { isMine(it, me) }
}
return innerApplyFilter(newItems)
}
private fun isMine(
note: Note,
me: String,
): Boolean {
val noteEvent = note.event
return noteEvent is MusicPlaylistEvent && noteEvent.pubKey == me
}
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(

View File

@@ -55,15 +55,32 @@ class MusicTracksFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it ->
accept(it, params)
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> isMine(it, me) }
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> accept(it, params) }
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
return newItems.filterTo(HashSet()) { isMine(it, me) }
}
return innerApplyFilter(newItems)
}
private fun isMine(
note: Note,
me: String,
): Boolean {
val noteEvent = note.event
return noteEvent is MusicTrackEvent && noteEvent.pubKey == me
}
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(

View File

@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -52,6 +54,12 @@ class MusicPlaylistsSubAssembler(
key: MusicPlaylistsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own playlists by author
// against their outbox relays (same pattern as badges/communities).
if (key.listName() == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
return filterMusicEventsMine(key.account.userProfile().pubkeyHex, MUSIC_PLAYLIST_KINDS, outbox, since)
}
val feedSettings = key.followsPerRelay()
// REQ now only asks for kind 34139 (playlists), keyed to this screen's follow
// list selector — no cross-feed cursor min needed.

View File

@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -42,6 +44,12 @@ class MusicTracksSubAssembler(
key: MusicTracksQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own tracks by author
// against their outbox relays (same pattern as badges/communities).
if (key.listName() == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
return filterMusicEventsMine(key.account.userProfile().pubkeyHex, MUSIC_TRACK_KINDS, outbox, since)
}
val feedSettings = key.followsPerRelay()
// REQ now only asks for kind 36787 (tracks), so the `since` cursor lines up with
// the tracks feed alone — no cross-feed min needed.

View File

@@ -0,0 +1,54 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Builds the relay filters for the "Mine" music selector: the user's own tracks/playlists,
* queried by author against their outbox relays. Mirrors `filterBadgesMine` /
* `filterCommunitiesMine`. The `kinds` list scopes it to tracks (36787) or playlists (34139).
*/
fun filterMusicEventsMine(
pubkey: HexKey,
kinds: List<Int>,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
authors = authors,
limit = 200,
since = since?.get(relay)?.time,
),
)
}
}

View File

@@ -1791,6 +1791,22 @@
<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="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>

View File

@@ -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,73 @@ 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 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,
title: String,
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)
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)
// 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)
}
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,

View File

@@ -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")
}
}