mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +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.AddToMusicPlaylistSheet
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen
|
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.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.music.NewMusicTrackScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen
|
||||||
@@ -313,6 +314,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
|
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = 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) }
|
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
|
||||||
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
|
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
|
||||||
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||||
|
|||||||
@@ -175,6 +175,11 @@ sealed class Route {
|
|||||||
val dTag: String? = null,
|
val dTag: String? = null,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewMusicPlaylist(
|
||||||
|
val dTag: String? = null,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AddToMusicPlaylist(
|
data class AddToMusicPlaylist(
|
||||||
val trackAddress: String,
|
val trackAddress: String,
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ fun MusicPlaylistHeader(
|
|||||||
val trackAddresses = remember(noteEvent) { noteEvent.trackAddresses() }
|
val trackAddresses = remember(noteEvent) { noteEvent.trackAddresses() }
|
||||||
val isCollaborative = remember(noteEvent) { noteEvent.isCollaborative() }
|
val isCollaborative = remember(noteEvent) { noteEvent.isCollaborative() }
|
||||||
val isPrivate = remember(noteEvent) { noteEvent.isPrivate() }
|
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) {
|
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||||
MusicPlaylistCover(image, note, trackAddresses.size, accountViewModel)
|
MusicPlaylistCover(image, note, trackAddresses.size, accountViewModel)
|
||||||
@@ -168,7 +171,10 @@ fun MusicPlaylistHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
symbol = MaterialSymbols.AutoMirrored.PlaylistAdd,
|
symbol = MaterialSymbols.AutoMirrored.PlaylistAdd,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -188,6 +194,20 @@ fun MusicPlaylistHeader(
|
|||||||
if (isPrivate) {
|
if (isPrivate) {
|
||||||
PlaylistTag(text = stringRes(R.string.music_playlist_private))
|
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 {
|
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 =
|
private val _kind3GlobalPeople =
|
||||||
livePeopleListsFlow.transform { peopleLists ->
|
livePeopleListsFlow.transform { peopleLists ->
|
||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
@@ -341,6 +359,11 @@ class TopNavFilterState(
|
|||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
|
.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() {
|
fun destroy() {
|
||||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||||
}
|
}
|
||||||
|
|||||||
+244
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -88,7 +88,7 @@ fun MusicPlaylistsScreen(
|
|||||||
},
|
},
|
||||||
floatingButton = {
|
floatingButton = {
|
||||||
FabBottomBarPadded(nav) {
|
FabBottomBarPadded(nav) {
|
||||||
NewMusicPlaylistFab(accountViewModel)
|
NewMusicPlaylistFab(nav)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
|
|||||||
+3
-2
@@ -63,8 +63,9 @@ private fun MusicPlaylistsTopNavFilterBar(
|
|||||||
onChange: (FeedDefinition) -> Unit,
|
onChange: (FeedDefinition) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Same route catalog as MusicTracks (and Articles / Longs): All Follows, Your Follows,
|
// Same route catalog as MusicTracks (and Articles / Longs): All Follows, Your Follows,
|
||||||
// kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list.
|
// kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list — plus
|
||||||
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
|
// "Mine" for the user's own published playlists.
|
||||||
|
val allLists by followListsModel.musicRoutes.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
FeedFilterSpinner(
|
FeedFilterSpinner(
|
||||||
placeholderCode = listName,
|
placeholderCode = listName,
|
||||||
|
|||||||
+4
-4
@@ -62,10 +62,10 @@ private fun MusicTracksTopNavFilterBar(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onChange: (FeedDefinition) -> Unit,
|
onChange: (FeedDefinition) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Music is content-style (like Articles / Long-form), so reuse the same route catalog
|
// Music is content-style (like Articles / Long-form), so reuse that route catalog — All
|
||||||
// as those feeds — All Follows, Your Follows, kind3 Follows, Around Me, Global, custom
|
// Follows, Your Follows, kind3 Follows, Around Me, Global, custom people lists, interest
|
||||||
// people lists, interest sets, mute list.
|
// sets, mute list — plus "Mine" for the user's own published tracks.
|
||||||
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
|
val allLists by followListsModel.musicRoutes.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
FeedFilterSpinner(
|
FeedFilterSpinner(
|
||||||
placeholderCode = listName,
|
placeholderCode = listName,
|
||||||
|
|||||||
+9
-79
@@ -20,43 +20,30 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.music
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.music
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
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.FloatingActionButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.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.graphics.Color
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
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.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) {
|
fun NewMusicPlaylistFab(nav: INav) {
|
||||||
var dialogOpen by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
FloatingActionButton(
|
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,
|
modifier = Size55Modifier,
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
@@ -68,69 +55,12 @@ fun NewMusicPlaylistFab(accountViewModel: AccountViewModel) {
|
|||||||
tint = Color.White,
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewMusicPlaylistFabPreview() {
|
private fun NewMusicPlaylistFabPreview() {
|
||||||
ThemeComparisonRow {
|
ThemeComparisonRow {
|
||||||
NewMusicPlaylistFab(accountViewModel = mockAccountViewModel())
|
NewMusicPlaylistFab(nav = EmptyNav())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+527
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+348
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-130
@@ -23,18 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -44,7 +41,6 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -61,23 +57,19 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
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.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
|
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
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.EmptyNav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
|
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
|
// 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
|
// done while the other is still running), and the banner gives clear
|
||||||
// top-of-screen feedback that something IS happening.
|
// top-of-screen feedback that something IS happening.
|
||||||
if (isBusy) UploadInProgressBanner()
|
if (isBusy) UploadInProgressBanner(R.string.music_track_uploading_banner)
|
||||||
|
|
||||||
CoverImagePicker(
|
CoverImagePicker(
|
||||||
vm = vm,
|
cover = vm.coverMedia.value,
|
||||||
accountViewModel = accountViewModel,
|
existingUrl = vm.coverUrl.value,
|
||||||
onPick = { wantsToPickCover = true },
|
onPick = { wantsToPickCover = true },
|
||||||
|
onDelete = { vm.clearPickedCover() },
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
|
ctaRes = R.string.music_track_cover_upload_cta,
|
||||||
|
hintRes = R.string.music_track_cover_upload_hint,
|
||||||
)
|
)
|
||||||
|
|
||||||
AudioFilePicker(
|
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
|
@Composable
|
||||||
private fun AudioFilePicker(
|
private fun AudioFilePicker(
|
||||||
vm: NewMusicTrackViewModel,
|
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
|
// 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
|
// 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.
|
// let the user pick a PDF and try to publish it as a track — not what this screen is for.
|
||||||
|
|||||||
+3
@@ -155,6 +155,9 @@ class NewMusicTrackViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun clearPickedCover() {
|
fun clearPickedCover() {
|
||||||
coverMedia.value = null
|
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(
|
fun setPickedAudio(
|
||||||
|
|||||||
+21
-4
@@ -54,15 +54,32 @@ class MusicPlaylistsFeedFilter(
|
|||||||
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
val params = buildFilterParams(account)
|
|
||||||
val notes =
|
val notes =
|
||||||
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it ->
|
if (followList() == TopFilter.Mine) {
|
||||||
accept(it, params)
|
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)
|
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 =
|
fun buildFilterParams(account: Account): FilterByListParams =
|
||||||
FilterByListParams.create(
|
FilterByListParams.create(
|
||||||
|
|||||||
+21
-4
@@ -55,15 +55,32 @@ class MusicTracksFeedFilter(
|
|||||||
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
val params = buildFilterParams(account)
|
|
||||||
val notes =
|
val notes =
|
||||||
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it ->
|
if (followList() == TopFilter.Mine) {
|
||||||
accept(it, params)
|
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)
|
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 =
|
fun buildFilterParams(account: Account): FilterByListParams =
|
||||||
FilterByListParams.create(
|
FilterByListParams.create(
|
||||||
|
|||||||
+8
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.TopFilter
|
|||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
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.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||||
@@ -52,6 +54,12 @@ class MusicPlaylistsSubAssembler(
|
|||||||
key: MusicPlaylistsQueryState,
|
key: MusicPlaylistsQueryState,
|
||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
): List<RelayBasedFilter> {
|
): 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()
|
val feedSettings = key.followsPerRelay()
|
||||||
// REQ now only asks for kind 34139 (playlists), keyed to this screen's follow
|
// REQ now only asks for kind 34139 (playlists), keyed to this screen's follow
|
||||||
// list selector — no cross-feed cursor min needed.
|
// list selector — no cross-feed cursor min needed.
|
||||||
|
|||||||
+8
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.TopFilter
|
|||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
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.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||||
@@ -42,6 +44,12 @@ class MusicTracksSubAssembler(
|
|||||||
key: MusicTracksQueryState,
|
key: MusicTracksQueryState,
|
||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
): List<RelayBasedFilter> {
|
): 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()
|
val feedSettings = key.followsPerRelay()
|
||||||
// REQ now only asks for kind 36787 (tracks), so the `since` cursor lines up with
|
// REQ now only asks for kind 36787 (tracks), so the `since` cursor lines up with
|
||||||
// the tracks feed alone — no cross-feed min needed.
|
// the tracks feed alone — no cross-feed min needed.
|
||||||
|
|||||||
+54
@@ -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">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_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="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>
|
<string name="loading_location">Loading location</string>
|
||||||
|
|||||||
+68
@@ -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.DescriptionTag
|
||||||
import com.vitorpamplona.quartz.experimental.music.playlist.tags.ImageTag
|
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.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.playlist.tags.TitleTag
|
||||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
@@ -138,6 +139,73 @@ class MusicPlaylistEvent(
|
|||||||
initializer()
|
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,
|
* 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,
|
* preserving every other tag of `earlierVersion` (extra `t` genre tags, custom metadata,
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.experimental.music.playlist
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class MusicPlaylistEventEditTest {
|
||||||
|
private val pubkey = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"
|
||||||
|
|
||||||
|
private val trackA = Address(MusicTrackEvent.KIND, pubkey, "track-a")
|
||||||
|
private val trackB = Address(MusicTrackEvent.KIND, pubkey, "track-b")
|
||||||
|
private val trackC = Address(MusicTrackEvent.KIND, pubkey, "track-c")
|
||||||
|
|
||||||
|
// An `a` tag that does NOT point at a music track (e.g. a curated article). Must survive edit.
|
||||||
|
private val articleRef = Address(30023, pubkey, "some-article")
|
||||||
|
|
||||||
|
private fun earlierVersion(): MusicPlaylistEvent =
|
||||||
|
MusicPlaylistEvent(
|
||||||
|
id = "00",
|
||||||
|
pubKey = pubkey,
|
||||||
|
createdAt = 0L,
|
||||||
|
tags =
|
||||||
|
arrayOf(
|
||||||
|
arrayOf("d", "pl1"),
|
||||||
|
arrayOf("title", "Old Title"),
|
||||||
|
arrayOf("t", "playlist"),
|
||||||
|
arrayOf("t", "custom-genre"),
|
||||||
|
arrayOf("image", "https://old.example/cover.jpg"),
|
||||||
|
arrayOf("description", "old desc"),
|
||||||
|
arrayOf("a", "${MusicTrackEvent.KIND}:$pubkey:track-a"),
|
||||||
|
arrayOf("a", "30023:$pubkey:some-article"),
|
||||||
|
arrayOf("a", "${MusicTrackEvent.KIND}:$pubkey:track-b"),
|
||||||
|
arrayOf("a", "${MusicTrackEvent.KIND}:$pubkey:track-c"),
|
||||||
|
arrayOf("public", "true"),
|
||||||
|
),
|
||||||
|
content = "old notes",
|
||||||
|
sig = "00",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun resultOf(
|
||||||
|
tracks: List<Address>,
|
||||||
|
isPrivate: Boolean = false,
|
||||||
|
isCollaborative: Boolean = false,
|
||||||
|
): MusicPlaylistEvent {
|
||||||
|
val template =
|
||||||
|
MusicPlaylistEvent.edit(
|
||||||
|
earlierVersion = earlierVersion(),
|
||||||
|
title = "New Title",
|
||||||
|
content = "new notes",
|
||||||
|
image = "https://new.example/cover.jpg",
|
||||||
|
description = "new desc",
|
||||||
|
tracks = tracks,
|
||||||
|
isPrivate = isPrivate,
|
||||||
|
isCollaborative = isCollaborative,
|
||||||
|
)
|
||||||
|
return MusicPlaylistEvent("00", pubkey, 0L, template.tags, template.content, "00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reordersTracksToTheGivenOrder() {
|
||||||
|
val result = resultOf(tracks = listOf(trackC, trackA, trackB))
|
||||||
|
assertEquals(listOf(trackC, trackA, trackB), result.trackAddresses())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removesDroppedTracks() {
|
||||||
|
val result = resultOf(tracks = listOf(trackA, trackC))
|
||||||
|
assertEquals(listOf(trackA, trackC), result.trackAddresses())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updatesComposerOwnedMetadata() {
|
||||||
|
val result = resultOf(tracks = listOf(trackA), isPrivate = true, isCollaborative = true)
|
||||||
|
assertEquals("New Title", result.title())
|
||||||
|
assertEquals("https://new.example/cover.jpg", result.image())
|
||||||
|
assertEquals("new desc", result.description())
|
||||||
|
assertEquals("new notes", result.content)
|
||||||
|
assertTrue(result.isPrivate())
|
||||||
|
assertTrue(result.isCollaborative())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchingToPrivateClearsThePublicFlag() {
|
||||||
|
val result = resultOf(tracks = listOf(trackA), isPrivate = true)
|
||||||
|
val tagsByName = result.tags.groupBy { it[0] }
|
||||||
|
assertFalse(tagsByName.containsKey("public"), "stale public flag must be dropped")
|
||||||
|
assertTrue(tagsByName.containsKey("private"))
|
||||||
|
assertTrue(result.isPrivate())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preservesDTagCustomHashtagsAndNonTrackReferences() {
|
||||||
|
val result = resultOf(tracks = listOf(trackA, trackB, trackC))
|
||||||
|
val tagsByName = result.tags.groupBy { it[0] }
|
||||||
|
|
||||||
|
// Same addressable identity → same d tag.
|
||||||
|
assertEquals("pl1", tagsByName["d"]!!.single()[1])
|
||||||
|
|
||||||
|
// Custom hashtags untouched (both the category marker and the user's genre tag).
|
||||||
|
val tValues = tagsByName["t"]!!.map { it[1] }
|
||||||
|
assertTrue(tValues.contains("playlist"))
|
||||||
|
assertTrue(tValues.contains("custom-genre"))
|
||||||
|
|
||||||
|
// The non-track `a` reference survives the track reset.
|
||||||
|
val aValues = tagsByName["a"]!!.map { it[1] }
|
||||||
|
assertTrue(aValues.contains("30023:$pubkey:some-article"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearingCoverRemovesTheImageTag() {
|
||||||
|
val template =
|
||||||
|
MusicPlaylistEvent.edit(
|
||||||
|
earlierVersion = earlierVersion(),
|
||||||
|
title = "New Title",
|
||||||
|
content = "",
|
||||||
|
image = null,
|
||||||
|
description = null,
|
||||||
|
tracks = listOf(trackA),
|
||||||
|
isPrivate = false,
|
||||||
|
isCollaborative = false,
|
||||||
|
)
|
||||||
|
val tagsByName = template.tags.groupBy { it[0] }
|
||||||
|
assertFalse(tagsByName.containsKey("image"), "null cover must remove the image tag")
|
||||||
|
assertFalse(tagsByName.containsKey("description"), "null description must remove the tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user