mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat: full-screen music playlist composer with cover image and metadata
Replace the name-only "new playlist" dialog with a full-screen create/edit composer reachable via a dedicated route (Route.NewMusicPlaylist). The composer surfaces a cover-image upload (same Blossom/NIP-96 pipeline as the music-track composer), plus title, short description, long-form notes, a public/private toggle and a collaborative toggle. An edit affordance now appears on the user's own playlist cards. - quartz: add MusicPlaylistEvent.edit() — updates the composer-owned metadata while preserving the track `a` tags and every other tag of the prior version. - amethyst: NewMusicPlaylistViewModel + NewMusicPlaylistScreen (create/edit/ delete); extract the shared cover-picker/placeholder/progress-banner into MusicComposerUploadUi so the track composer reuses them; FAB now navigates to the route; add edit icon on owned playlist cards; new string resources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
This commit is contained in:
@@ -141,6 +141,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
|
||||
@@ -312,6 +313,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 {
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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.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.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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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. When a file is
|
||||
* picked it shows the upload preview (tap to swap, close button to remove); otherwise it shows 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?,
|
||||
onPick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
enabled: Boolean,
|
||||
ctaRes: Int,
|
||||
hintRes: Int,
|
||||
) {
|
||||
if (cover != null) {
|
||||
Box(modifier = if (enabled) Modifier.clickable(onClick = onPick) else Modifier) {
|
||||
ShowImageUploadGallery(
|
||||
list = cover,
|
||||
onDelete = { if (enabled) onDelete() },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
UploadPlaceholder(
|
||||
iconSymbol = MaterialSymbols.AddPhotoAlternate,
|
||||
ctaRes = ctaRes,
|
||||
hintRes = hintRes,
|
||||
onClick = onPick,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -88,7 +88,7 @@ fun MusicPlaylistsScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewMusicPlaylistFab(accountViewModel)
|
||||
NewMusicPlaylistFab(nav)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+9
-79
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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.layout.Arrangement
|
||||
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.rememberScrollState
|
||||
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.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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
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.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
|
||||
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 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,
|
||||
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 },
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* 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)
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
// 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 = ""
|
||||
}
|
||||
|
||||
/** 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,
|
||||
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 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,
|
||||
isPrivate = snapshot.isPrivate,
|
||||
isCollaborative = snapshot.isCollaborative,
|
||||
)
|
||||
} else {
|
||||
MusicPlaylistEvent.build(
|
||||
title = snapshot.title,
|
||||
content = snapshot.notes,
|
||||
image = coverUrl,
|
||||
description = snapshot.description,
|
||||
isPrivate = snapshot.isPrivate,
|
||||
isCollaborative = snapshot.isCollaborative,
|
||||
)
|
||||
}
|
||||
|
||||
account.signAndComputeBroadcast(template)
|
||||
}
|
||||
|
||||
suspend fun deleteLoaded(): Boolean {
|
||||
val target = loadedEvent ?: return false
|
||||
account.delete(target, emptySet())
|
||||
return true
|
||||
}
|
||||
}
|
||||
+6
-130
@@ -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,16 @@ 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,
|
||||
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 +249,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 +306,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.
|
||||
|
||||
@@ -1790,6 +1790,18 @@
|
||||
<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="loading_location">Loading location</string>
|
||||
|
||||
+52
@@ -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,57 @@ 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 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?,
|
||||
isPrivate: Boolean,
|
||||
isCollaborative: Boolean,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventTemplate<MusicPlaylistEvent> {
|
||||
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)
|
||||
}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user