mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+3
@@ -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) {
|
||||
|
||||
+8
-4
@@ -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)
|
||||
|
||||
+207
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user