feat: V4V split editor in the podcast show and episode composers

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-06-28 21:29:00 +00:00
parent 6db98c2a73
commit cd73020dd8
7 changed files with 345 additions and 8 deletions
@@ -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)
}
}
}
@@ -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,
@@ -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) {
@@ -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<String>,
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)
@@ -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)) },
)
}
}
}
@@ -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<RecipientDraft>()
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
}
}
}
+12
View File
@@ -1004,6 +1004,18 @@
<item quantity="one">%1$d trailer</item>
<item quantity="other">%1$d trailers</item>
</plurals>
<string name="podcast_value_editor_hint">Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations.</string>
<string name="podcast_value_add_recipient">Add recipient</string>
<string name="podcast_value_remove_recipient">Remove recipient</string>
<string name="podcast_value_recipient_name">Name (optional)</string>
<string name="podcast_value_type_lnaddress">Lightning address</string>
<string name="podcast_value_type_node">Node (keysend)</string>
<string name="podcast_value_lnaddress">Lightning address</string>
<string name="podcast_value_lnaddress_hint">name@example.com</string>
<string name="podcast_value_node_pubkey">Node pubkey</string>
<string name="podcast_value_node_pubkey_hint">02abc… (33-byte hex)</string>
<string name="podcast_value_weight">Weight</string>
<string name="podcast_value_fee">Fee</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d episode</item>
<item quantity="other">%1$d episodes</item>