mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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}" }
|
||||
}
|
||||
|
||||
+91
-19
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+1
@@ -153,6 +153,7 @@ fun NewMusicPlaylistScreen(
|
||||
|
||||
CoverImagePicker(
|
||||
cover = vm.coverMedia.value,
|
||||
existingUrl = vm.coverUrl.value,
|
||||
onPick = { wantsToPickCover = true },
|
||||
onDelete = { vm.clearPickedCover() },
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+1
@@ -169,6 +169,7 @@ fun NewMusicTrackScreen(
|
||||
|
||||
CoverImagePicker(
|
||||
cover = vm.coverMedia.value,
|
||||
existingUrl = vm.coverUrl.value,
|
||||
onPick = { wantsToPickCover = true },
|
||||
onDelete = { vm.clearPickedCover() },
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
+21
-4
@@ -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(
|
||||
|
||||
+21
-4
@@ -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(
|
||||
|
||||
+8
@@ -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.
|
||||
|
||||
+8
@@ -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.
|
||||
|
||||
+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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user