From cd73020dd8fea43f296200f3c46f4748f3bfcf89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:29:00 +0000 Subject: [PATCH] feat: V4V split editor in the podcast show and episode composers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a creator define their value-for-value splits in-app, closing the create→get-paid loop: set up recipients on the show (and override per episode), publish, and listeners' boosts/streams fan out to those destinations. Adds a reusable V4VSplitEditorState + V4VSplitEditor composable: a card with one row per recipient (name, Lightning-address vs node/keysend toggle, address, weight, optional fee) and an "Add recipient" action, showing each recipient's live percentage of the total weight. toPodcastValue() rebuilds the PodcastValue on save (null when there are no payable recipients); a loaded block's suggested amount/currency/enabled are carried through untouched. Wired into both composers, replacing the previous preserve-only passthrough: - Show editor: edits the show-level split (kind:30078 value block). - Episode editor: edits the episode-level override (in More details). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../authoring/EditPodcastShowScreen.kt | 2 + .../authoring/EditPodcastShowViewModel.kt | 9 +- .../authoring/NewPodcastEpisodeScreen.kt | 3 + .../authoring/NewPodcastEpisodeViewModel.kt | 12 +- .../podcasts/authoring/V4VSplitEditor.kt | 207 ++++++++++++++++++ .../podcasts/authoring/V4VSplitEditorState.kt | 108 +++++++++ amethyst/src/main/res/values/strings.xml | 12 + 7 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt index 7cdfb9ad8e..ebfa77b3bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -169,6 +169,8 @@ fun EditPodcastShowScreen( SwitchRow(stringRes(R.string.podcast_show_explicit), vm.explicit.value) { vm.explicit.value = it } SwitchRow(stringRes(R.string.podcast_show_complete), vm.complete.value) { vm.complete.value = it } SwitchRow(stringRes(R.string.podcast_show_locked), vm.locked.value) { vm.locked.value = it } + + V4VSplitEditor(vm.splitEditor) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt index 1c82ce3b88..d45f9b922f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata -import com.vitorpamplona.quartz.podcasts.PodcastValue import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -82,9 +81,11 @@ class EditPodcastShowViewModel : ViewModel() { val mediaQualitySlider = mutableStateOf(1) val stripMetadata = mutableStateOf(true) + /** Editable value-for-value split for the show. */ + val splitEditor = V4VSplitEditorState() + /** Fields the editor doesn't surface but must not drop on save. */ private var preservedGuid: String? = null - private var preservedValue: PodcastValue? = null private var hasExisting = false fun init(accountViewModel: AccountViewModel) { @@ -98,7 +99,7 @@ class EditPodcastShowViewModel : ViewModel() { if (existing != null) { hasExisting = true preservedGuid = existing.guid() - preservedValue = existing.showValue() + splitEditor.load(existing.showValue()) title.value = existing.showTitle().orEmpty() description.value = existing.showDescription().orEmpty() author.value = existing.showAuthor().orEmpty() @@ -162,7 +163,7 @@ class EditPodcastShowViewModel : ViewModel() { type = type.value.trim().ifBlank { null }, complete = complete.value.takeIf { it }, guid = preservedGuid, - value = preservedValue, + value = splitEditor.toPodcastValue(), ), coverOrchestrator = coverOrch, server = server, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt index 1b593d324a..9741a4f522 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -250,6 +250,9 @@ fun NewPodcastEpisodeScreen( singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), ) + + // Episode-level V4V override; leave empty to inherit the show's split. + V4VSplitEditor(vm.splitEditor) } if (vm.isEditing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt index 0ac2b4f3bc..d2309ea701 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt @@ -88,9 +88,11 @@ class NewPodcastEpisodeViewModel : ViewModel() { private var dTag: String? = null private var loadedEvent: Podcasting20EpisodeEvent? = null - /** Carried across an edit so we don't drop the original publish date or value splits on save. */ + /** Editable value-for-value split for this episode (overrides the show's split). */ + val splitEditor = V4VSplitEditorState() + + /** Carried across an edit so we don't drop the original publish date on save. */ private var preservedPubDate: String? = null - private var preservedValue: PodcastValue? = null val isEditing: Boolean get() = loadedEvent != null @@ -110,7 +112,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { dTag = editDTag loadedEvent = existing preservedPubDate = existing.pubDate() - preservedValue = existing.value() + splitEditor.load(existing.value()) title.value = existing.title().orEmpty() description.value = existing.description().orEmpty() audioUrl.value = @@ -196,6 +198,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { transcriptUrl = transcriptUrl.value.trim().ifBlank { null }, chaptersUrl = chaptersUrl.value.trim().ifBlank { null }, topics = PodcastComposerMedia.parseCsv(topics.value), + value = splitEditor.toPodcastValue(), coverOrchestrator = coverMedia.value, audioOrchestrator = audioMedia.value, existingCoverUrl = coverUrl.value.trim().ifBlank { null }, @@ -255,6 +258,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { val transcriptUrl: String?, val chaptersUrl: String?, val topics: List, + val value: PodcastValue?, val coverOrchestrator: MultiOrchestrator?, val audioOrchestrator: MultiOrchestrator?, val existingCoverUrl: String?, @@ -315,7 +319,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { season = snapshot.season, transcriptUrl = snapshot.transcriptUrl, chaptersUrl = snapshot.chaptersUrl, - value = preservedValue, + value = snapshot.value, topics = snapshot.topics, ) account.signAndComputeBroadcast(template) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt new file mode 100644 index 0000000000..6b8057c8f3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -0,0 +1,207 @@ +/* + * 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.podcasts.authoring + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.grayText + +/** + * Editor for a Podcasting-2.0 value-for-value split: a card listing each recipient (name, lnaddress + * vs node toggle, address, weight, optional fee) plus an "Add recipient" action. Each recipient's + * share of incoming sats is shown live as a percentage of the total weight. Drives a + * [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue] on save. + */ +@Composable +fun V4VSplitEditor(state: V4VSplitEditorState) { + val total = state.totalSplit() + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.podcast_value_for_value), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (state.recipients.isEmpty()) { + Text( + text = stringRes(R.string.podcast_value_editor_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + + state.recipients.forEach { draft -> + RecipientCard( + draft = draft, + total = total, + onRemove = { state.remove(draft) }, + ) + } + + TextButton(onClick = { state.add() }, modifier = Modifier.fillMaxWidth()) { + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(text = stringRes(R.string.podcast_value_add_recipient), modifier = Modifier.padding(start = 6.dp)) + } + } +} + +@Composable +private fun RecipientCard( + draft: RecipientDraft, + total: Int, + onRemove: () -> Unit, +) { + val isNode by draft.isNode + val weight = + draft.split.value + .trim() + .toIntOrNull() ?: 0 + val percent = if (total > 0 && weight > 0) weight * 100 / total else 0 + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(R.string.podcast_value_remove_recipient), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + OutlinedTextField( + value = draft.name.value, + onValueChange = { draft.name.value = it }, + label = { Text(stringRes(R.string.podcast_value_recipient_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = !isNode, + onClick = { draft.isNode.value = false }, + label = { Text(stringRes(R.string.podcast_value_type_lnaddress)) }, + ) + FilterChip( + selected = isNode, + onClick = { draft.isNode.value = true }, + label = { Text(stringRes(R.string.podcast_value_type_node)) }, + ) + } + + OutlinedTextField( + value = draft.address.value, + onValueChange = { draft.address.value = it }, + label = { + Text( + if (isNode) { + stringRes(R.string.podcast_value_node_pubkey) + } else { + stringRes(R.string.podcast_value_lnaddress) + }, + ) + }, + placeholder = { + Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = draft.address.value.isBlank(), + ) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft.split.value, + onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_value_weight)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = weight <= 0, + ) + FilterChip( + selected = draft.fee.value, + onClick = { draft.fee.value = !draft.fee.value }, + label = { Text(stringRes(R.string.podcast_value_fee)) }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt new file mode 100644 index 0000000000..7177854346 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt @@ -0,0 +1,108 @@ +/* + * 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.podcasts.authoring + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient + +/** + * Editable state for a Podcasting-2.0 value-for-value (V4V) split, shared by the show and episode + * composers. Holds one [RecipientDraft] per payee; [toPodcastValue] turns the drafts back into a + * [PodcastValue] on save (or null when there are no payable recipients). The suggested amount / + * currency / enabled flag on a loaded block are carried through untouched — the editor only manages + * the recipient list. + */ +class V4VSplitEditorState { + val recipients = mutableStateListOf() + + private var amount: Long? = null + private var currency: String? = null + private var enabled: Boolean? = null + + fun load(value: PodcastValue?) { + recipients.clear() + amount = value?.amount + currency = value?.currency + enabled = value?.enabled + value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) } + } + + fun add() { + recipients.add(RecipientDraft()) + } + + fun remove(draft: RecipientDraft) { + recipients.remove(draft) + } + + /** Sum of the weights of the payable recipients — used to show each as a percentage. */ + fun totalSplit(): Int = recipients.mapNotNull { it.toRecipient() }.sumOf { it.split } + + fun toPodcastValue(): PodcastValue? { + val valid = recipients.mapNotNull { it.toRecipient() } + if (valid.isEmpty()) return null + return PodcastValue( + enabled = enabled, + amount = amount, + currency = currency, + recipients = valid, + ) + } +} + +/** One editable recipient row. All fields are Compose state so the editor recomposes as they change. */ +class RecipientDraft { + val name = mutableStateOf("") + + /** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */ + val isNode = mutableStateOf(false) + val address = mutableStateOf("") + val split = mutableStateOf("1") + val fee = mutableStateOf(false) + + /** A recipient is payable once it has an address and a positive weight. */ + fun toRecipient(): PodcastValueRecipient? { + val addr = address.value.trim() + if (addr.isBlank()) return null + val weight = split.value.trim().toIntOrNull() ?: return null + if (weight <= 0) return null + return PodcastValueRecipient( + name = name.value.trim().ifBlank { null }, + type = if (isNode.value) PodcastValue.TYPE_NODE else PodcastValue.TYPE_LNADDRESS, + address = addr, + split = weight, + fee = if (fee.value) true else null, + ) + } + + companion object { + fun from(recipient: PodcastValueRecipient): RecipientDraft = + RecipientDraft().apply { + name.value = recipient.name.orEmpty() + isNode.value = recipient.type == PodcastValue.TYPE_NODE + address.value = recipient.address.orEmpty() + split.value = recipient.split.toString() + fee.value = recipient.fee == true + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ab1c969588..58d11807f3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1004,6 +1004,18 @@ %1$d trailer %1$d trailers + Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. + Add recipient + Remove recipient + Name (optional) + Lightning address + Node (keysend) + Lightning address + name@example.com + Node pubkey + 02abc… (33-byte hex) + Weight + Fee %1$d episode %1$d episodes