mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Merge pull request #3251 from vitorpamplona/claude/hopeful-cori-qpq0ug
Add music playlist composer screen and edit support
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -175,6 +175,11 @@ sealed class Route {
|
||||
val dTag: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class NewMusicPlaylist(
|
||||
val dTag: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class AddToMusicPlaylist(
|
||||
val trackAddress: String,
|
||||
|
||||
@@ -146,6 +146,9 @@ fun MusicPlaylistHeader(
|
||||
val trackAddresses = remember(noteEvent) { noteEvent.trackAddresses() }
|
||||
val isCollaborative = remember(noteEvent) { noteEvent.isCollaborative() }
|
||||
val isPrivate = remember(noteEvent) { noteEvent.isPrivate() }
|
||||
// The composer can only resolve and edit a playlist the logged-in user authored (it loads the
|
||||
// addressable from their own pubkey), so only surface the edit affordance for owned playlists.
|
||||
val isOwnPlaylist = remember(note) { accountViewModel.isLoggedUser(note.author) }
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
MusicPlaylistCover(image, note, trackAddresses.size, accountViewModel)
|
||||
@@ -168,7 +171,10 @@ fun MusicPlaylistHeader(
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.PlaylistAdd,
|
||||
contentDescription = null,
|
||||
@@ -188,6 +194,20 @@ fun MusicPlaylistHeader(
|
||||
if (isPrivate) {
|
||||
PlaylistTag(text = stringRes(R.string.music_playlist_private))
|
||||
}
|
||||
if (isOwnPlaylist) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
contentDescription = stringRes(R.string.music_playlist_edit_action),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { nav.nav(Route.NewMusicPlaylist(dTag = noteEvent.dTag())) }
|
||||
.padding(4.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
shortDescription?.let {
|
||||
|
||||
@@ -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}" }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ fun MusicPlaylistsScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewMusicPlaylistFab(accountViewModel)
|
||||
NewMusicPlaylistFab(nav)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,43 +20,30 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.music
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) {
|
||||
var dialogOpen by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
fun NewMusicPlaylistFab(nav: INav) {
|
||||
FloatingActionButton(
|
||||
onClick = { dialogOpen = true },
|
||||
// Opens the full-screen playlist composer in create mode (no d-tag). The composer
|
||||
// owns title, cover-image upload, description, notes, visibility and the collaborative
|
||||
// flag — the old name-only dialog couldn't surface any of those.
|
||||
onClick = { nav.nav(Route.NewMusicPlaylist()) },
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
@@ -68,69 +55,12 @@ fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) {
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
NewEmptyPlaylistDialog(
|
||||
onDismiss = { dialogOpen = false },
|
||||
onCreate = { name ->
|
||||
// Empty playlist: user adds tracks later via the "Add to playlist" sheet on
|
||||
// any music-track note. `name` is already trimmed by the dialog's confirm
|
||||
// button, no need to .trim() again here.
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.signAndComputeBroadcast(
|
||||
MusicPlaylistEvent.build(title = name),
|
||||
)
|
||||
// Compose state writes belong on the main dispatcher — launchSigner is
|
||||
// Dispatchers.IO, so we hop back before flipping the dialog flag.
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
dialogOpen = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewEmptyPlaylistDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
) {
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(R.string.music_playlist_new_dialog_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCreate(name.trim()) },
|
||||
enabled = name.trim().isNotBlank(),
|
||||
) {
|
||||
Text(stringRes(R.string.music_playlist_create_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun NewMusicPlaylistFabPreview() {
|
||||
ThemeComparisonRow {
|
||||
NewMusicPlaylistFab(accountViewModel = mockAccountViewModel())
|
||||
NewMusicPlaylistFab(nav = EmptyNav())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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