feat: add "Mine" option to music + playlist feeds; fix cover in editor

Two changes:

1. "Mine" top-nav filter for the Music and Playlists feeds. Adds a shared
   `musicRoutes` option list (the content-style catalog plus "Mine") to
   TopNavFilterState and points both music top bars at it. The local-cache
   feed filters and the relay sub-assemblers now handle TopFilter.Mine by
   restricting to the logged-in user's own tracks/playlists (by author, over
   their outbox relays) — same pattern as the badges/communities feeds.

2. Fix: editing a track/playlist showed the empty upload placeholder even when
   the event already had a cover. The shared CoverImagePicker now renders the
   already-published cover URL (with tap-to-replace and a remove button) when no
   new local file is picked. The track composer's clearPickedCover now also
   clears the saved URL so "remove cover" sticks on save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
This commit is contained in:
Claude
2026-06-17 21:14:55 +00:00
parent 3ee699c555
commit 3b9cca19cf
12 changed files with 238 additions and 33 deletions
@@ -292,6 +292,24 @@ class TopNavFilterState(
)
}
private val _musicRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
) { peopleLists, interests ->
checkNotInMainThread()
emit(
listOf(
// Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the
// music + playlists screens can show only the user's own published items.
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
peopleLists,
interests,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
@@ -341,6 +359,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow))
val musicRoutes =
_musicRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -20,6 +20,7 @@
*/
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
@@ -31,6 +32,7 @@ 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
@@ -39,9 +41,12 @@ 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
@@ -51,15 +56,22 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Square cover-image picker shared by the music track and playlist composers. When a file is
* picked it shows the upload preview (tap to swap, close button to remove); otherwise it shows the
* dashed upload placeholder. While an upload is in flight (`enabled = false`) the tap/delete
* gestures are dropped so the user can't mutate the selection mid-upload, but the preview stays
* visible so they can see what's being sent.
* 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,
@@ -67,22 +79,82 @@ fun CoverImagePicker(
ctaRes: Int,
hintRes: Int,
) {
if (cover != null) {
Box(modifier = if (enabled) Modifier.clickable(onClick = onPick) else Modifier) {
ShowImageUploadGallery(
list = cover,
onDelete = { if (enabled) onDelete() },
accountViewModel = accountViewModel,
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,
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),
)
}
}
}
}
@@ -63,8 +63,9 @@ private fun MusicPlaylistsTopNavFilterBar(
onChange: (FeedDefinition) -> Unit,
) {
// Same route catalog as MusicTracks (and Articles / Longs): All Follows, Your Follows,
// kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list.
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
// kind3 Follows, Around Me, Global, custom people lists, interest sets, mute list — plus
// "Mine" for the user's own published playlists.
val allLists by followListsModel.musicRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
@@ -62,10 +62,10 @@ private fun MusicTracksTopNavFilterBar(
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
// Music is content-style (like Articles / Long-form), so reuse the same route catalog
// as those feeds — All Follows, Your Follows, kind3 Follows, Around Me, Global, custom
// people lists, interest sets, mute list.
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
// Music is content-style (like Articles / Long-form), so reuse that route catalog — All
// Follows, Your Follows, kind3 Follows, Around Me, Global, custom people lists, interest
// sets, mute list — plus "Mine" for the user's own published tracks.
val allLists by followListsModel.musicRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
@@ -153,6 +153,7 @@ fun NewMusicPlaylistScreen(
CoverImagePicker(
cover = vm.coverMedia.value,
existingUrl = vm.coverUrl.value,
onPick = { wantsToPickCover = true },
onDelete = { vm.clearPickedCover() },
accountViewModel = accountViewModel,
@@ -169,6 +169,7 @@ fun NewMusicTrackScreen(
CoverImagePicker(
cover = vm.coverMedia.value,
existingUrl = vm.coverUrl.value,
onPick = { wantsToPickCover = true },
onDelete = { vm.clearPickedCover() },
accountViewModel = accountViewModel,
@@ -155,6 +155,9 @@ class NewMusicTrackViewModel : ViewModel() {
fun clearPickedCover() {
coverMedia.value = null
// Also drop the already-published cover URL so the remove gesture removes the existing
// artwork on save (MusicTrackEvent.edit treats a blank image as "remove the tag").
coverUrl.value = ""
}
fun setPickedAudio(
@@ -54,15 +54,32 @@ class MusicPlaylistsFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it ->
accept(it, params)
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> isMine(it, me) }
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> accept(it, params) }
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
return newItems.filterTo(HashSet()) { isMine(it, me) }
}
return innerApplyFilter(newItems)
}
private fun isMine(
note: Note,
me: String,
): Boolean {
val noteEvent = note.event
return noteEvent is MusicPlaylistEvent && noteEvent.pubKey == me
}
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
@@ -55,15 +55,32 @@ class MusicTracksFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it ->
accept(it, params)
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> isMine(it, me) }
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> accept(it, params) }
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
return newItems.filterTo(HashSet()) { isMine(it, me) }
}
return innerApplyFilter(newItems)
}
private fun isMine(
note: Note,
me: String,
): Boolean {
val noteEvent = note.event
return noteEvent is MusicTrackEvent && noteEvent.pubKey == me
}
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -52,6 +54,12 @@ class MusicPlaylistsSubAssembler(
key: MusicPlaylistsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own playlists by author
// against their outbox relays (same pattern as badges/communities).
if (key.listName() == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
return filterMusicEventsMine(key.account.userProfile().pubkeyHex, MUSIC_PLAYLIST_KINDS, outbox, since)
}
val feedSettings = key.followsPerRelay()
// REQ now only asks for kind 34139 (playlists), keyed to this screen's follow
// list selector — no cross-feed cursor min needed.
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -42,6 +44,12 @@ class MusicTracksSubAssembler(
key: MusicTracksQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own tracks by author
// against their outbox relays (same pattern as badges/communities).
if (key.listName() == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
return filterMusicEventsMine(key.account.userProfile().pubkeyHex, MUSIC_TRACK_KINDS, outbox, since)
}
val feedSettings = key.followsPerRelay()
// REQ now only asks for kind 36787 (tracks), so the `since` cursor lines up with
// the tracks feed alone — no cross-feed min needed.
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Builds the relay filters for the "Mine" music selector: the user's own tracks/playlists,
* queried by author against their outbox relays. Mirrors `filterBadgesMine` /
* `filterCommunitiesMine`. The `kinds` list scopes it to tracks (36787) or playlists (34139).
*/
fun filterMusicEventsMine(
pubkey: HexKey,
kinds: List<Int>,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
authors = authors,
limit = 200,
since = since?.get(relay)?.time,
),
)
}
}