Merge pull request #3441 from vitorpamplona/claude/podcast-event-kinds-merge-vv24gd

Add podcast authoring UI and NIP-XX Podcasting 2.0 support
This commit is contained in:
Vitor Pamplona
2026-07-01 17:16:46 -04:00
committed by GitHub
116 changed files with 9725 additions and 145 deletions
@@ -289,6 +289,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
@@ -3752,7 +3754,12 @@ object LocalCache : ILocalCache, ICacheProvider {
}
is PodcastMetadataEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
// Drop the known "Mock Podcast" spam flood instead of caching thousands of them.
if (event.isMockSpam()) {
false
} else {
consumeBaseReplaceable(event, relay, wasVerified)
}
}
is AuthoredPodcastsEvent -> {
@@ -3763,6 +3770,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20EpisodeEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20TrailerEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is LnZapEvent -> {
consume(event, relay, wasVerified)
}
@@ -0,0 +1,289 @@
/*
* 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.service
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.podcasts.PodcastValueShare
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
/**
* Executes a Podcasting-2.0 value-for-value (V4V) split: takes a [PodcastValue] block and a total
* amount, computes each recipient's share ([PodcastValue.computeShares]) and pays them.
*
* This is the V4V analogue of [ZapPaymentHandler]. The recipients are raw Lightning destinations
* declared in the value block, but the two kinds are paid very differently:
*
* - [PodcastValue.TYPE_LNADDRESS] — resolved to a BOLT-11 via LNURL-pay and paid through the user's
* default payment source (NWC, CLINK debit, or — when none is set — handed to an external wallet
* via [onPayInvoicesViaIntent]). Same rails as a zap, and when `asZap` is set each share also
* carries a NIP-57 zap request, so a Nostr-aware lnaddress provider issues a real **zap receipt**
* (this is what lets the standard zap button drive a V4V split with its usual icon/counter UI).
* Per-minute streaming pays with `asZap = false` to avoid publishing a receipt every minute.
* - [PodcastValue.TYPE_NODE] — paid by **keysend** (NIP-47 `pay_keysend`) carrying the Podcasting-2.0
* boostagram TLV ([PodcastValue.PODCAST_TLV_RECORD]) plus any per-recipient custom TLV. There is no
* LNURL endpoint and no invoice, so keysend can never produce a zap receipt regardless of `asZap`.
* Keysend is only available over NWC, so node recipients are skipped (with an error) when no NWC
* wallet is set up.
*/
class V4VPaymentHandler(
val account: Account,
) {
/** A resolved lnaddress share ready to pay: the share plus the BOLT-11 fetched for it. */
class InvoicePayable(
val share: PodcastValueShare,
val invoice: String,
)
suspend fun pay(
value: PodcastValue,
totalMilliSats: Long,
boostagram: PodcastBoostagram,
zappedNote: Note?,
context: Context,
okHttpClient: (String) -> OkHttpClient,
onError: (title: String, message: String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoicesViaIntent: (invoices: List<String>) -> Unit,
asZap: Boolean = false,
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
) = withContext(Dispatchers.IO) {
val shares = value.computeShares(totalMilliSats)
if (shares.isEmpty()) {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_no_recipients),
)
return@withContext
}
val nodeShares = shares.filter { it.recipient.type == PodcastValue.TYPE_NODE }
val lnAddressShares = shares.filter { it.recipient.type == PodcastValue.TYPE_LNADDRESS }
onProgress(0.05f)
// Keysend (node) recipients can only be paid over NWC.
if (nodeShares.isNotEmpty()) {
if (account.nip47SignerState.hasWalletConnectSetup()) {
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
} else {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_keysend_requires_nwc),
)
}
}
if (lnAddressShares.isNotEmpty()) {
val payables =
assembleInvoices(
shares = lnAddressShares,
message = boostagram.message.orEmpty(),
asZap = asZap,
zapType = zapType,
zappedNote = zappedNote,
okHttpClient = okHttpClient,
context = context,
onError = onError,
onProgress = { onProgress(it * 0.6f + 0.1f) },
)
payInvoices(payables, zappedNote, context, onError, onPayInvoicesViaIntent) {
onProgress(it * 0.25f + 0.7f)
}
}
onProgress(1f)
}
/** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */
private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
private suspend fun payNodeSharesViaKeysend(
shares: List<PodcastValueShare>,
boostagram: PodcastBoostagram,
context: Context,
onError: (String, String) -> Unit,
) {
val metadataTlv = TlvRecord(PodcastValue.PODCAST_TLV_RECORD, hexTlv(boostagram.toJson()))
shares.forEach { share ->
val pubkey = share.recipient.address ?: return@forEach
val tlvRecords = mutableListOf(metadataTlv)
val customType = share.recipient.customKey?.toLongOrNull()
val customValue = share.recipient.customValue
if (customType != null && customValue != null) {
tlvRecords.add(TlvRecord(customType, hexTlv(customValue)))
}
val request =
PayKeysendMethod.create(
amount = share.amountMilliSats,
pubkey = pubkey,
tlvRecords = tlvRecords,
)
account.sendNwcRequest(request) { response: Response? ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response.errorMessage()
?: stringRes(context, R.string.error_parsing_error_message),
)
}
}
}
}
private suspend fun assembleInvoices(
shares: List<PodcastValueShare>,
message: String,
asZap: Boolean,
zapType: LnZapEvent.ZapType,
zappedNote: Note?,
okHttpClient: (String) -> OkHttpClient,
context: Context,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
): List<InvoicePayable> {
// When paying as a zap, attach a NIP-57 request to each share so the recipient's LNURL
// provider mints a zappable invoice and publishes a receipt. The receipt is attributed to
// the zapped note (toUser = null) since a value-block lnaddress is a raw payee, not
// necessarily a Nostr identity. Send to the show/episode author's inbox so they see it.
val noteEvent = zappedNote?.event
val authorRelays = zappedNote?.author?.inboxRelays()?.toSet() ?: emptySet()
var progress = 0f
return mapNotNullAsync(shares) { share: PodcastValueShare ->
val lnAddress = share.recipient.address ?: return@mapNotNullAsync null
try {
val nostrRequest =
if (asZap && noteEvent != null) {
account.createZapRequestFor(
event = noteEvent,
pollOption = null,
message = message,
zapType = zapType,
toUser = null,
additionalRelays = authorRelays,
amountMillisats = share.amountMilliSats,
lnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32),
)
} else {
null
}
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lnAddress,
milliSats = share.amountMilliSats,
message = message,
nostrRequest = nostrRequest,
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
progress += 1f / shares.size
onProgress(progress)
InvoicePayable(share, invoice)
} catch (e: LightningAddressResolver.LightningAddressError) {
onError(e.title, e.msg)
null
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
e.message ?: stringRes(context, R.string.error_parsing_error_message),
)
null
}
}
}
private suspend fun payInvoices(
payables: List<InvoicePayable>,
zappedNote: Note?,
context: Context,
onError: (String, String) -> Unit,
onPayInvoicesViaIntent: (List<String>) -> Unit,
onProgress: (percent: Float) -> Unit,
) {
if (payables.isEmpty()) return
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.Nwc -> {
var done = 0
payables.forEach { payable ->
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response.errorMessage()
?: stringRes(context, R.string.error_parsing_error_message),
)
}
}
done++
onProgress(done.toFloat() / payables.size)
}
}
is PaymentSource.ClinkDebit -> {
var done = 0
payables.forEach { payable ->
val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice)
if (response?.isOk() != true) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
)
}
done++
onProgress(done.toFloat() / payables.size)
}
}
null -> {
onPayInvoicesViaIntent(payables.map { it.invoice })
onProgress(1f)
}
}
}
}
@@ -0,0 +1,64 @@
/*
* 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.service.podcasts
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
/**
* Fetches the off-event side files a podcast episode references by URL — the Podcasting-2.0
* `chapters.json` document and the `transcript` file — so the client can render them in-app.
* A bounded read cap keeps a hostile/huge file from blowing up memory.
*/
object PodcastRemoteContent {
/** Refuse bodies larger than this (chapters/transcripts are small text files). */
private const val MAX_BYTES = 2_000_000L
suspend fun fetchText(
url: String,
okHttpClient: OkHttpClient,
): String? =
withContext(Dispatchers.IO) {
try {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) return@use null
val body = response.body ?: return@use null
// Reject an oversized declared length outright; cap the read for chunked bodies.
if (body.contentLength() > MAX_BYTES) return@use null
body.string().take(MAX_BYTES.toInt())
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
}
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
@@ -145,6 +146,7 @@ class RelaySubscriptionsCoordinator(
val podcastEpisodes = PodcastEpisodesFilterAssembler(client)
val podcasts = PodcastsFilterAssembler(client)
val onePodcast = OnePodcastFilterAssembler(client)
val myPodcast = MyPodcastFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val napplets = NappletsFilterAssembler(client)
val connectedApps = ConnectedAppsFilterAssembler(client)
@@ -198,6 +200,7 @@ class RelaySubscriptionsCoordinator(
podcastEpisodes,
podcasts,
onePodcast,
myPodcast,
softwareApps,
badges,
profileBadges,
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.BookmarkedPodcastsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
@@ -179,6 +180,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScre
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastEpisodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -373,6 +378,10 @@ fun BuildNavigation(
composableFromEnd<Route.PodcastEpisodes> { PodcastEpisodesScreen(accountViewModel, nav) }
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
composableFromEnd<Route.PodcastAuthoring> { PodcastAuthoringScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPodcastShow> { EditPodcastShowScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NewPodcastEpisode> { NewPodcastEpisodeScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewPodcastTrailer> { NewPodcastTrailerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.NewMusicPlaylist> { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
@@ -446,6 +455,7 @@ fun BuildNavigation(
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
composableFromEnd<Route.BookmarkedRepositories> { BookmarkedRepositoriesScreen(accountViewModel, nav) }
composableFromEnd<Route.BookmarkedPodcasts> { BookmarkedPodcastsScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
@@ -196,6 +196,17 @@ sealed class Route {
val pubkey: String,
) : Route()
@Serializable object PodcastAuthoring : Route()
@Serializable object EditPodcastShow : Route()
@Serializable
data class NewPodcastEpisode(
val dTag: String? = null,
) : Route()
@Serializable object NewPodcastTrailer : Route()
@Serializable
data class NewMusicTrack(
val dTag: String? = null,
@@ -304,6 +315,8 @@ sealed class Route {
@Serializable object BookmarkedRepositories : Route()
@Serializable object BookmarkedPodcasts : Route()
@Serializable object BookmarkGroups : Route()
@Serializable object InterestSets : Route()
@@ -44,13 +44,14 @@ import com.vitorpamplona.amethyst.ui.theme.isLight
fun TopBarExtensibleWithBackButton(
title: @Composable RowScope.() -> Unit,
extendableRow: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
popBack: () -> Unit,
) {
MyExtensibleTopAppBar(
title = title,
extendableRow = extendableRow,
navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } },
actions = {},
actions = actions,
)
}
@@ -198,6 +198,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
@@ -325,6 +326,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
@@ -344,6 +346,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -982,10 +987,39 @@ private fun RenderNoteRow(
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is Podcasting20EpisodeEvent -> {
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is Podcasting20TrailerEvent -> {
PodcastTrailerListItem(baseNote, accountViewModel, nav)
}
is PodcastMetadataEvent -> {
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is AppSpecificDataEvent -> {
// kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a
// podcast card. Anything else (e.g. a client's own settings) keeps the text fallback.
if (noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) {
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
} else {
RenderTextEvent(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
editState,
accountViewModel,
nav,
isBoostedNote = isBoostedNote,
)
}
}
is DraftWrapEvent -> {
RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
}
@@ -0,0 +1,109 @@
/*
* 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.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* A bookmark toggle for a podcast (show or episode). "Favoriting"/subscribing to a podcast reuses
* the existing NIP-51 bookmark list (kind 10003) — a public bookmark matches the soft public
* recommendation the dedicated favorites list (10054) was meant for, and the note then appears in
* the standard Bookmarks screen. Works for both regular events (e-tag) and addressable shows/
* episodes (a-tag) because [AccountViewModel.addPublicBookmark] branches on the note type.
*
* Bookmarked state is read from the public bookmark id/address **sets** (not `List<Note>`
* containment) so it reflects reliably for addressable notes and updates the moment the list
* changes — the icon flipping filled/outline is the feedback, no toast needed.
*
* Rendered as a compact clickable glyph (not a Material [androidx.compose.material3.IconButton],
* whose 48dp minimum touch target would stand taller than the title it sits beside). [iconSize]
* defaults to a single title line so it aligns when placed next to a show/episode title.
*/
@Composable
fun PodcastBookmarkButton(
note: Note,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
) {
val bookmarkState = accountViewModel.account.bookmarkState
val publicAddresses by bookmarkState.publicBookmarkAddressIdSet.collectAsStateWithLifecycle()
val publicEvents by bookmarkState.publicBookmarkEventIdSet.collectAsStateWithLifecycle()
val isBookmarked =
remember(note, publicAddresses, publicEvents) {
if (note is AddressableNote) {
note.address in publicAddresses
} else {
note.idHex in publicEvents
}
}
Box(
modifier =
modifier
.clip(CircleShape)
.clickable(
role = Role.Button,
onClick = {
if (isBookmarked) {
accountViewModel.removePublicBookmark(note)
} else {
accountViewModel.addPublicBookmark(note)
}
},
).padding(4.dp),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
contentDescription =
stringRes(
if (isBookmarked) R.string.remove_from_public_bookmarks else R.string.add_to_public_bookmarks,
),
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(iconSize),
)
}
}
@@ -0,0 +1,161 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastChapter
import com.vitorpamplona.quartz.podcasts.PodcastChapters
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
private sealed interface ChaptersUiState {
data object Loading : ChaptersUiState
data class Loaded(
val chapters: List<PodcastChapter>,
) : ChaptersUiState
data object Failed : ChaptersUiState
}
/**
* Fetches the episode's off-event Podcasting-2.0 chapters document on first composition and renders
* it as a tinted list of `timestamp — title` rows. Fetch is lazy (callers gate it behind an expand
* toggle) so scrolling a feed never triggers network. On failure or empty, renders nothing.
*/
@Composable
fun PodcastChaptersSection(
chaptersUrl: String,
accountViewModel: AccountViewModel,
) {
val state by produceState<ChaptersUiState>(ChaptersUiState.Loading, chaptersUrl) {
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl)
val parsed = loadChapters(chaptersUrl, client)
value = if (parsed != null) ChaptersUiState.Loaded(parsed.chapters) else ChaptersUiState.Failed
}
when (val current = state) {
is ChaptersUiState.Loading ->
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
is ChaptersUiState.Failed -> {}
is ChaptersUiState.Loaded -> {
val chapters = current.chapters
if (chapters.isEmpty()) return
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
chapters.forEach { chapter ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = formatTimestamp(chapter.startSeconds()),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.widthIn(min = 44.dp),
)
Text(
text = chapter.title ?: stringRes(R.string.podcast_chapters),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
private suspend fun loadChapters(
url: String,
client: OkHttpClient,
): PodcastChapters? =
withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
client.newCall(request).executeAsync().use { response ->
if (response.isSuccessful) PodcastChapters.parse(response.body.string()) else null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PodcastChapters", "Failed to load chapters from $url", e)
null
}
}
private fun formatTimestamp(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, secs)
} else {
"%d:%02d".format(minutes, secs)
}
}
@@ -0,0 +1,167 @@
/*
* 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.note.types
import androidx.compose.foundation.clickable
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastChapter
import com.vitorpamplona.quartz.podcasts.PodcastChapters
/**
* The episode's Podcasting-2.0 chapters (from the off-event `chapters.json` referenced by the
* `chapters` tag), fetched and rendered as a collapsible, tappable list. Tapping a chapter calls
* [onSeek] with its start in milliseconds so the host can seek the live media controller — the same
* contract as [PodcastSoundbites].
*/
@Composable
fun PodcastChaptersView(
chaptersUrl: String,
onSeek: (startMillis: Long) -> Unit,
accountViewModel: AccountViewModel,
) {
val chapters by produceState(initialValue = emptyList<PodcastChapter>(), chaptersUrl) {
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl)
val body = PodcastRemoteContent.fetchText(chaptersUrl, client)
value = body?.let { PodcastChapters.parse(it)?.chapters }?.filter { it.title?.isNotBlank() == true } ?: emptyList()
}
if (chapters.isEmpty()) return
var expanded by remember(chaptersUrl) { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
CollapsibleHeader(
symbol = MaterialSymbols.AutoMirrored.FormatListBulleted,
title = pluralStringResource(R.plurals.podcast_chapters_count, chapters.size, chapters.size),
expanded = expanded,
onToggle = { expanded = !expanded },
)
if (expanded) {
chapters.forEach { chapter ->
ChapterRow(chapter, onSeek)
}
}
}
}
@Composable
private fun ChapterRow(
chapter: PodcastChapter,
onSeek: (Long) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onSeek(chapter.startSeconds() * 1000) }
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = formatChapterTime(chapter.startTime),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
Text(
text = chapter.title.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
@Composable
internal fun CollapsibleHeader(
symbol: MaterialSymbol,
title: String,
expanded: Boolean,
onToggle: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.grayText,
)
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.grayText,
)
}
}
private fun formatChapterTime(totalSeconds: Double): String {
val total = totalSeconds.toLong()
val h = total / 3600
val m = (total % 3600) / 60
val s = total % 60
val two = { n: Long -> if (n < 10) "0$n" else "$n" }
return if (h > 0) "$h:${two(m)}:${two(s)}" else "$m:${two(s)}"
}
@@ -0,0 +1,111 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
/**
* A small rounded, tinted pill for a podcast attribute (explicit, completed, a genre, a season/
* episode number, …). Shared by the show and episode renderers so they read as one design.
*/
@Composable
internal fun PodcastBadge(
label: String,
symbol: MaterialSymbol?,
container: Color,
content: Color,
) {
Row(
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(container)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
symbol?.let {
Icon(
symbol = it,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = content,
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = content,
)
}
}
/** A clickable pill that opens an external resource (a website, a transcript, chapters, …). */
@Composable
internal fun PodcastLinkChip(
label: String,
symbol: MaterialSymbol,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,27 +33,36 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
// Bottom-rounded border on the audio player so it visually butts up against the cover's
// top-rounded corners as one card. Constant — keep out of recomposition.
private val PLAYER_BORDER_MODIFIER =
Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp))
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderPodcastEpisode(
note: Note,
@@ -60,21 +72,32 @@ fun RenderPodcastEpisode(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastEpisodeEvent ?: return
val noteEvent = note.event ?: return
// Both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054) episodes implement PodcastEpisode,
// so this one renderer serves both. Title/image/description/audio come through the shared
// abstraction; content and tags come from the underlying event.
val episode = noteEvent as? PodcastEpisode ?: return
val title = remember(noteEvent) { noteEvent.title() }
val image = remember(noteEvent) { noteEvent.image() }
val description = remember(noteEvent) { noteEvent.description() }
// Pick the first audio URL. Publishers may emit multiple containers in their preferred
// order; clients with codec preferences can extend this later.
val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() }
val title = remember(noteEvent) { episode.episodeTitle() }
val image = remember(noteEvent) { episode.episodeImage() }
val description = remember(noteEvent) { episode.episodeDescription() }
// Prefer audio (podcasts are audio-first); fall back to the video source if that's all the
// episode ships. The media-controller player handles both.
val media = remember(noteEvent) { episode.episodeAudio().firstOrNull() ?: episode.episodeVideo() }
val hasVideo = remember(noteEvent) { episode.episodeVideo() != null }
val season = remember(noteEvent) { episode.episodeSeason() }
val episodeNumber = remember(noteEvent) { episode.episodeNumber() }
val transcriptUrl = remember(noteEvent) { episode.episodeTranscriptUrl() }
val chaptersUrl = remember(noteEvent) { episode.episodeChaptersUrl() }
val value = remember(noteEvent) { episode.episodeValue() }
var chaptersExpanded by remember(noteEvent) { mutableStateOf(false) }
// Suppress the markdown block if blank — title + description already describe a short
// episode. Otherwise hand off to RichText below.
val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } }
Column(MaterialTheme.colorScheme.replyModifier) {
PodcastCoverCard(image, note, accountViewModel)
firstAudio?.let { audio ->
media?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
@@ -92,15 +115,67 @@ fun RenderPodcastEpisode(
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
PodcastBookmarkButton(note, accountViewModel)
}
if (season != null || episodeNumber != null || hasVideo || transcriptUrl != null || chaptersUrl != null) {
val uriHandler = LocalUriHandler.current
val seasonEpisodeLabel =
when {
season != null && episodeNumber != null -> stringRes(R.string.podcast_season_episode, season, episodeNumber)
episodeNumber != null -> stringRes(R.string.podcast_episode_number, episodeNumber)
season != null -> stringRes(R.string.podcast_season, season)
else -> null
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
seasonEpisodeLabel?.let {
PodcastBadge(
label = it,
symbol = null,
container = MaterialTheme.colorScheme.secondaryContainer,
content = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
if (hasVideo) {
PodcastBadge(
label = stringRes(R.string.podcast_video),
symbol = MaterialSymbols.Videocam,
container = MaterialTheme.colorScheme.tertiaryContainer,
content = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
transcriptUrl?.let { url ->
PodcastLinkChip(stringRes(R.string.podcast_transcript), MaterialSymbols.Description) {
runCatching { uriHandler.openUri(url) }
}
}
chaptersUrl?.let {
PodcastLinkChip(stringRes(R.string.podcast_chapters), MaterialSymbols.Checklist) {
chaptersExpanded = !chaptersExpanded
}
}
}
}
if (chaptersExpanded) {
chaptersUrl?.let { PodcastChaptersSection(it, accountViewModel) }
}
description?.let {
@@ -119,6 +194,18 @@ fun RenderPodcastEpisode(
)
}
value?.takeIf { !makeItShort }?.let {
PodcastValueSplits(value = it)
}
if (!makeItShort) {
val persons = remember(noteEvent) { episode.episodePersons() }
PodcastPeople(persons, accountViewModel, nav)
val transcriptUrl = remember(noteEvent) { episode.episodeTranscriptUrl() }
transcriptUrl?.let { PodcastTranscriptView(it, accountViewModel) }
}
markdown?.takeIf { !makeItShort }?.let {
Spacer(Modifier.padding(top = 4.dp))
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -35,7 +36,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
// The voice player's internal controls are laid out for 100.dp; 80.dp is the tightest height
// that still fits the play button without clipping it. Shared so the feed card and the
@@ -43,13 +45,14 @@ import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
private val PLAYER_HEIGHT_MODIFIER = Modifier.fillMaxWidth().height(80.dp)
/**
* The inline audio strip for a NIP-F4 episode (kind 54): one [AudioTag] played through the
* shared media-controller stack. [borderModifier] shapes the strip — bottom-rounded when it
* butts up under a cover image, fully rounded when it stands alone in a list.
* The inline audio strip for a podcast episode: one [PodcastAudio] played through the shared
* media-controller stack. Works for both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054)
* episodes via the spec-neutral audio holder. [borderModifier] shapes the strip — bottom-rounded
* when it butts up under a cover image, fully rounded when it stands alone in a list.
*/
@Composable
fun PodcastEpisodeAudioPlayer(
audio: AudioTag,
audio: PodcastAudio,
note: Note,
title: String?,
image: String?,
@@ -69,10 +72,18 @@ fun PodcastEpisodeAudioPlayer(
?: syntheticWaveformFor(note.idHex)
}
Row(
PLAYER_HEIGHT_MODIFIER,
verticalAlignment = Alignment.CenterVertically,
) {
// The episode's value-for-value block, if any — drives the per-minute streaming control below
// the player. Pulled through the spec-neutral PodcastEpisode interface so both kinds work.
val value = remember(note) { (note.event as? PodcastEpisode)?.episodeValue() }
// Highlight clips (Podcasting-2.0 soundbites) — rendered under the player so a tap can seek the
// live controller to the clip's start.
val soundbites = remember(note) { (note.event as? PodcastEpisode)?.episodeSoundbites().orEmpty() }
// Off-event chapters document URL, if any — the list seeks the live controller too.
val chaptersUrl = remember(note) { (note.event as? PodcastEpisode)?.episodeChaptersUrl() }
Column(Modifier.fillMaxWidth()) {
GetMediaItem(
videoUri = audio.url,
title = title,
@@ -90,13 +101,45 @@ fun PodcastEpisodeAudioPlayer(
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = borderModifier,
accountViewModel = accountViewModel,
)
Row(
PLAYER_HEIGHT_MODIFIER,
verticalAlignment = Alignment.CenterVertically,
) {
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = borderModifier,
accountViewModel = accountViewModel,
)
}
value?.let {
PodcastStreamingControl(
value = it,
note = note,
episodeName = title,
podcastName = null,
controllerState = controller,
accountViewModel = accountViewModel,
)
}
PodcastSoundbites(soundbites) { startMillis ->
controller.controller.seekTo(startMillis)
controller.controller.play()
}
chaptersUrl?.let { url ->
PodcastChaptersView(
chaptersUrl = url,
onSeek = { startMillis ->
controller.controller.seekTo(startMillis)
controller.controller.play()
},
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -29,6 +29,7 @@ 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.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -37,6 +38,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -53,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -65,14 +67,23 @@ fun RenderPodcastMetadata(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastMetadataEvent ?: return
val noteEvent = note.event ?: return
// Resolves NIP-F4 kind:10154 and Podcasting-2.0 kind:30078 shows to one PodcastShow view.
val show = remember(noteEvent) { resolvePodcastShow(noteEvent) } ?: return
val title = remember(noteEvent) { noteEvent.title() }
val image = remember(noteEvent) { noteEvent.image() }
val description = remember(noteEvent) { noteEvent.description() }
val websites = remember(noteEvent) { noteEvent.websites() }
// Each podcast is its own keypair, so the author pubkey IS the podcast id used to open
// its dedicated screen with the full episode list.
val title = remember(noteEvent) { show.showTitle() }
val author = remember(noteEvent) { show.showAuthor() }
val image = remember(noteEvent) { show.showImage() }
val description = remember(noteEvent) { show.showDescription() }
val websites = remember(noteEvent) { show.showWebsites() }
val categories = remember(noteEvent) { show.showCategories() }
val fundingUrls = remember(noteEvent) { show.showFundingUrls() }
val isExplicit = remember(noteEvent) { show.showIsExplicit() }
val isComplete = remember(noteEvent) { show.showIsComplete() }
val copyright = remember(noteEvent) { show.showCopyright() }
val value = remember(noteEvent) { show.showValue() }
// In both drafts the show's author pubkey IS the podcast id used to open its dedicated
// screen with the full episode list (episodes are authored by the same key).
val podcastPubkey = remember(noteEvent) { noteEvent.pubKey }
Column(
@@ -89,17 +100,65 @@ fun RenderPodcastMetadata(
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
title?.let {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
PodcastBookmarkButton(note, accountViewModel)
}
author?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
text = stringRes(R.string.podcast_by_author, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
if (isExplicit || isComplete || categories.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (isComplete) {
PodcastBadge(
label = stringRes(R.string.podcast_completed),
symbol = MaterialSymbols.CheckCircle,
container = MaterialTheme.colorScheme.tertiaryContainer,
content = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
if (isExplicit) {
PodcastBadge(
label = stringRes(R.string.podcast_explicit),
symbol = null,
container = MaterialTheme.colorScheme.errorContainer,
content = MaterialTheme.colorScheme.onErrorContainer,
)
}
categories.forEach { category ->
PodcastBadge(
label = category,
symbol = MaterialSymbols.Tag,
container = MaterialTheme.colorScheme.secondaryContainer,
content = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
description?.takeIf { !makeItShort }?.let {
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
@@ -116,23 +175,51 @@ fun RenderPodcastMetadata(
)
}
value?.takeIf { !makeItShort }?.let {
PodcastValueSplits(value = it)
}
if (fundingUrls.isNotEmpty() && !makeItShort) {
val uriHandler = LocalUriHandler.current
Button(
onClick = { runCatching { uriHandler.openUri(fundingUrls.first()) } },
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
) {
Icon(
symbol = MaterialSymbols.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onPrimary,
)
Text(
text = stringRes(R.string.podcast_support_show),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}
if (websites.isNotEmpty() && !makeItShort) {
val uriHandler = LocalUriHandler.current
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Size5dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
websites.forEach { website ->
Text(
text = website,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
PodcastLinkChip(website, MaterialSymbols.Public) { runCatching { uriHandler.openUri(website) } }
}
}
}
copyright?.takeIf { !makeItShort }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = Size5dp),
)
}
// Affordance that this card opens a full show page with every episode.
Row(
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
@@ -0,0 +1,206 @@
/*
* 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.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastPerson
/**
* A "Hosts & Guests" strip: the Podcasting-2.0 `podcast:person` credits for a show or episode,
* rendered as a horizontally scrollable row of avatar + name + role.
*
* A person is usually a free-text credit (name + image URL + web link), not a Nostr user, so it's
* drawn with the app's default profile-image loader and its link opens externally. But when the
* publisher's `href` points at an `npub`/`nprofile`, we upgrade the card to a real Nostr profile —
* the standard [ClickableUserPicture] + [UsernameDisplay], tappable through to the profile.
*/
@Composable
fun PodcastPeople(
persons: List<PodcastPerson>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val people = persons.filter { it.isValid() }
if (people.isEmpty()) return
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.podcast_hosts_and_guests),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(people) { person ->
PersonItem(person, accountViewModel, nav)
}
}
}
}
@Composable
private fun PersonItem(
person: PodcastPerson,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubKey = remember(person) { person.nostrPubKey() }
if (pubKey != null) {
LoadUser(pubKey, accountViewModel) { user ->
if (user != null) {
NostrPersonCard(user, person.role, accountViewModel, nav)
} else {
FreeTextPersonCard(person, accountViewModel)
}
}
} else {
FreeTextPersonCard(person, accountViewModel)
}
}
/** A person that resolved to a real Nostr identity — the standard profile treatment. */
@Composable
private fun NostrPersonCard(
user: User,
role: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
PersonCardScaffold(
onClick = { nav.nav(routeFor(user)) },
role = role,
avatar = { ClickableUserPicture(user, 56.dp, accountViewModel) },
name = {
UsernameDisplay(
user,
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
accountViewModel = accountViewModel,
)
},
)
}
/** A free-text `podcast:person` credit — default image loader, external link. */
@Composable
private fun FreeTextPersonCard(
person: PodcastPerson,
accountViewModel: AccountViewModel,
) {
val uriHandler = LocalUriHandler.current
val href = person.href
PersonCardScaffold(
onClick = href?.let { { runCatching { uriHandler.openUri(it) } } },
role = person.role,
avatar = {
RobohashFallbackAsyncImage(
robot = person.name,
model = person.img,
contentDescription = person.name,
modifier = Modifier.size(56.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
},
name = {
Text(
text = person.name,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
)
}
/** Shared 72dp centered card layout: avatar, name, and an optional role line. */
@Composable
private fun PersonCardScaffold(
onClick: (() -> Unit)?,
role: String?,
avatar: @Composable () -> Unit,
name: @Composable () -> Unit,
) {
Column(
modifier =
Modifier
.width(72.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
avatar()
name()
role?.takeIf { it.isNotEmpty() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -0,0 +1,90 @@
/*
* 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.note.types
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.quartz.podcasts.PodcastSoundbite
/**
* The episode's Podcasting-2.0 `podcast:soundbite` highlight clips as "jump to the good part" chips.
* Tapping one calls [onPlayFrom] with the clip's start offset in milliseconds; the host wires that
* to the media controller so playback seeks there. Kept controller-agnostic so it can live wherever
* a seek callback is available.
*/
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
fun PodcastSoundbites(
soundbites: List<PodcastSoundbite>,
onPlayFrom: (startMillis: Long) -> Unit,
) {
if (soundbites.isEmpty()) return
FlowRow(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
soundbites.forEach { soundbite ->
val label = soundbite.title?.takeIf { it.isNotBlank() } ?: formatClock(soundbite.startTimeSeconds)
AssistChip(
onClick = { onPlayFrom(soundbite.startMillis()) },
label = {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
)
},
leadingIcon = {
Icon(
symbol = MaterialSymbols.PlayArrow,
contentDescription = stringRes(R.string.podcast_play_soundbite),
modifier = Modifier.size(AssistChipDefaults.IconSize),
tint = MaterialTheme.colorScheme.primary,
)
},
)
}
}
}
private fun formatClock(totalSeconds: Double): String {
val total = totalSeconds.toLong()
val minutes = total / 60
val seconds = total % 60
val secStr = if (seconds < 10) "0$seconds" else "$seconds"
return "$minutes:$secStr"
}
@@ -0,0 +1,225 @@
/*
* 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.note.types
import android.content.Context
import android.media.AudioManager
import androidx.compose.foundation.clickable
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.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastStreamingAccrual
import com.vitorpamplona.quartz.podcasts.PodcastValue
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
// How often the accrual loop checks whether audio is genuinely playing. Cheap (one wake/second),
// and re-reading the live isPlaying each tick is what keeps streaming honest: if the player paused,
// went to the background, lost audio focus, errored, or was released, we simply stop accruing.
private const val STREAM_TICK_MS = 1_000L
// Per-minute rates the user can pick from. Deliberately small and explicit — streaming sends sats
// automatically, so the choices are bounded and the selected rate is always shown on the toggle.
private val STREAM_RATE_CHOICES = listOf(1L, 5L, 10L, 21L, 50L, 100L)
private const val DEFAULT_STREAM_RATE = 10L
/**
* Per-minute "streaming" payments for a podcast episode (Podcasting-2.0 value-for-value, streaming
* model). A switch — **off by default** — that, while on, sends the show/episode's [PodcastValue]
* split once per full minute of playback, at the chosen sats/minute rate.
*
* The whole point is that it must never pay while the user isn't listening, so accrual is bound
* tightly to real playback:
* - It lives inside the player composable, so navigating away disposes it and stops streaming.
* - Every second it re-reads the live [MediaControllerState.controller] and only accrues when audio
* is genuinely **audible**: playing, no playback error, in-app volume > 0, and system media volume
* > 0. `isPlaying` alone is not enough — a muted player (the player's mute button sets volume to 0)
* or a system volume of 0 keeps `isPlaying` true while the user hears nothing, and we must not
* spend then. The player also pauses itself on background / off-screen / error, stopping accrual.
* - Only whole, actually-played minutes are billed ([PodcastStreamingAccrual]); a partial minute is
* dropped when the session ends.
* - The toggle is gated to an in-app wallet (NWC/CLINK debit). Streaming to an external wallet app
* would mean firing a payment intent every minute, which we never do.
*/
@Composable
fun PodcastStreamingControl(
value: PodcastValue,
note: Note,
episodeName: String?,
podcastName: String?,
controllerState: MediaControllerState,
accountViewModel: AccountViewModel,
) {
val payableRecipients = remember(value) { value.recipients.count { it.split > 0 && !it.address.isNullOrBlank() } }
if (payableRecipients == 0) return
val context = LocalContext.current
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager }
val hasInAppWallet = remember { accountViewModel.account.settings.defaultPaymentSource() != null }
// Deliberately plain remember (not rememberSaveable): streaming must never silently resume after
// a rotation or process death. Any fresh creation of this control starts OFF; the user re-opts in.
var enabled by remember(note.idHex) { mutableStateOf(false) }
var rate by remember(note.idHex) { mutableStateOf(DEFAULT_STREAM_RATE) }
var rateMenuOpen by remember { mutableStateOf(false) }
var streamedSats by remember(note.idHex) { mutableLongStateOf(0L) }
if (enabled) {
// Restart the loop whenever the toggle, the player, or the rate changes; cancels (and so
// stops streaming) when this composable leaves the tree.
LaunchedEffect(controllerState, rate) {
val accrual = PodcastStreamingAccrual()
while (isActive) {
delay(STREAM_TICK_MS)
// Accrue only when audio is genuinely AUDIBLE, not merely "playing". isPlaying stays
// true when the player is muted (the player's mute button sets volume to 0) or when
// the system media volume is at 0 — in both cases the user hears nothing, so we must
// not spend. Require: playing, no error, in-app volume > 0, and system media volume > 0.
val audible =
runCatching {
controllerState.controller.isPlaying &&
controllerState.playbackError.value == null &&
controllerState.controller.volume > 0.001f &&
(audioManager?.let { it.getStreamVolume(AudioManager.STREAM_MUSIC) > 0 } ?: true)
}.getOrDefault(false)
if (audible) {
val minutes = accrual.accrue(STREAM_TICK_MS)
if (minutes > 0) {
val amount = minutes * rate
streamedSats += amount
accountViewModel.payV4V(
value = value,
totalSats = amount,
podcastName = podcastName,
episodeName = episodeName,
zappedNote = note,
context = context,
streaming = true,
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringRes(R.string.podcast_value_stream),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
// Rate chip — tap to change sats/minute.
Text(
text = stringRes(R.string.podcast_value_stream_rate, rate.toInt()),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { rateMenuOpen = true },
)
DropdownMenu(
expanded = rateMenuOpen,
onDismissRequest = { rateMenuOpen = false },
) {
STREAM_RATE_CHOICES.forEach { choice ->
DropdownMenuItem(
text = { Text(stringRes(R.string.podcast_value_stream_rate, choice.toInt())) },
onClick = {
rate = choice
rateMenuOpen = false
},
)
}
}
}
val status =
if (streamedSats > 0L) {
stringRes(R.string.podcast_value_streamed_total, streamedSats.toInt())
} else {
stringRes(R.string.podcast_value_stream_hint)
}
Text(
text = status,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(
checked = enabled,
onCheckedChange = { wantOn ->
if (wantOn && !hasInAppWallet) {
// No NWC/CLINK wallet -> we won't auto-stream; tell the user why and stay off.
accountViewModel.toastManager.toast(
R.string.podcast_value_error_title,
R.string.podcast_value_stream_requires_wallet,
)
} else {
enabled = wantOn
if (!wantOn) streamedSats = 0L
}
},
)
}
}
@@ -0,0 +1,111 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* The episode's transcript (from the off-event file referenced by the `transcript` tag), fetched and
* shown in a collapsible, scrollable panel. VTT/SRT scaffolding (the `WEBVTT` header, cue indices,
* and `-->` timing lines) is stripped so it reads as flowing text; plain-text transcripts pass
* through unchanged.
*/
@Composable
fun PodcastTranscriptView(
transcriptUrl: String,
accountViewModel: AccountViewModel,
) {
val transcript by produceState(initialValue = null as String?, transcriptUrl) {
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(transcriptUrl)
val body = PodcastRemoteContent.fetchText(transcriptUrl, client)
value = body?.let { cleanTranscript(it) }?.takeIf { it.isNotBlank() }
}
val text = transcript ?: return
var expanded by remember(transcriptUrl) { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
CollapsibleHeader(
symbol = MaterialSymbols.Description,
title = stringRes(R.string.podcast_transcript),
expanded = expanded,
onToggle = { expanded = !expanded },
)
if (expanded) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 320.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
/**
* Strips VTT/SRT caption scaffolding and joins the remaining caption lines into readable prose.
* Leaves plain-text transcripts effectively untouched. Falls back to the raw body if the cleanup
* removed everything (e.g. an unexpected format).
*/
private fun cleanTranscript(raw: String): String {
val out = StringBuilder()
for (line in raw.lineSequence()) {
val t = line.trim()
if (t.isEmpty()) continue
if (t == "WEBVTT") continue
if (t.startsWith("NOTE ")) continue
if (t.contains("-->")) continue // cue timing line
if (t.toIntOrNull() != null) continue // SRT cue index
out.append(t).append(' ')
}
return out.toString().trim().ifEmpty { raw.trim() }
}
@@ -0,0 +1,137 @@
/*
* 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.note.types
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.material3.MaterialTheme
import androidx.compose.material3.Text
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastValue
/**
* Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header, a
* one-line hint that zaps to this item are split, and one row per recipient (name/address + its
* share of the split).
*
* This is a **breakdown display only** — there is no dedicated send button. Paying the split is the
* job of the standard zap button: when a podcast note carries a value block, [AccountViewModel.zap]
* detects it and fans the chosen amount out to these recipients (lnaddress shares as real zaps, node
* shares as keysend). Keeping a separate "Send value" button here would just duplicate that action.
*/
@Composable
fun PodcastValueSplits(value: PodcastValue) {
val recipients = value.recipients.filter { it.split > 0 || it.address != null }
if (recipients.isEmpty()) return
val total = value.totalSplit().takeIf { it > 0 } ?: recipients.size
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.podcast_value_for_value),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
}
Text(
text = stringRes(R.string.podcast_value_zap_split_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
recipients.forEach { recipient ->
val label = recipient.name?.takeIf { it.isNotEmpty() } ?: recipient.address.orEmpty()
val percent = recipient.split * 100 / total
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
recipient.address
?.takeIf { it.isNotEmpty() && it != label }
?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = stringRes(R.string.podcast_value_split_percent, percent),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -328,6 +328,24 @@ class TopNavFilterState(
)
}
private val _podcastRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
) { peopleLists, interests ->
checkNotInMainThread()
emit(
listOf(
// Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the
// podcasts + episodes screens can show only the user's own published shows/episodes.
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
peopleLists,
interests,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
@@ -408,6 +426,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
val podcastRoutes =
_podcastRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
@@ -158,6 +160,10 @@ import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip92IMeta.imeta
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
import com.vitorpamplona.quartz.podcasts.PodcastShow
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -938,6 +944,27 @@ class AccountViewModel(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
zapType: LnZapEvent.ZapType? = null,
) = launchSigner {
// A podcast note (episode or show) can carry a Podcasting-2.0 value-for-value block. When it
// does, "zapping" it means paying that split — lnaddress recipients go out as real zaps (with
// receipts, which drive this same button's icon/counter), node recipients go out as keysend.
// This makes the standard zap button the single payment action for V4V content.
val v4v =
(note.event as? PodcastEpisode)?.episodeValue()
?: (note.event as? PodcastShow)?.showValue()
if (v4v != null && v4v.recipients.any { it.split > 0 && !it.address.isNullOrBlank() }) {
executeV4V(
value = v4v,
totalMilliSats = amountInMillisats,
podcastName = (note.event as? PodcastShow)?.showTitle(),
episodeName = (note.event as? PodcastEpisode)?.episodeTitle(),
zappedNote = note,
context = context,
streaming = false,
onProgress = onProgress,
)
return@launchSigner
}
val requestedType = zapType ?: defaultZapType()
// Zaps on private rumors are forced to PRIVATE so the sender and
@@ -965,6 +992,92 @@ class AccountViewModel(
)
}
/**
* Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in
* the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via
* NWC keysend with the boostagram TLV).
*
* [streaming] marks this as a per-minute streaming payment rather than a one-off boost: the
* boostagram action becomes "stream" and errors are swallowed instead of toasted — a streaming
* session fires once a minute and we don't want per-minute toast spam. One-off boosts surface
* errors on [toastManager]. The external-wallet intent fallback is skipped while [streaming]
* (you can't auto-fire a wallet app every minute); streaming is gated to NWC/CLINK callers.
*/
fun payV4V(
value: PodcastValue,
totalSats: Long,
podcastName: String?,
episodeName: String?,
zappedNote: Note?,
context: Context,
streaming: Boolean = false,
onProgress: (Float) -> Unit = {},
) = launchSigner {
executeV4V(
value = value,
totalMilliSats = totalSats * 1000,
podcastName = podcastName,
episodeName = episodeName,
zappedNote = zappedNote,
context = context,
streaming = streaming,
onProgress = onProgress,
)
}
/**
* Shared V4V execution used by both [payV4V] and the V4V reroute inside [zap]. Must be called
* from within a [launchSigner] block (it does signing). [streaming] = true marks per-minute
* payments: errors are swallowed (no per-minute toast spam), the external-wallet intent fallback
* is skipped (can't auto-launch a wallet every minute), and lnaddress shares are paid WITHOUT a
* zap request so streaming doesn't publish a receipt every minute. One-off boosts ([streaming] =
* false) pay lnaddress shares as real zaps, producing receipts that feed the zap button's UI.
*/
private suspend fun executeV4V(
value: PodcastValue,
totalMilliSats: Long,
podcastName: String?,
episodeName: String?,
zappedNote: Note?,
context: Context,
streaming: Boolean,
onProgress: (Float) -> Unit,
) {
val boostagram =
PodcastBoostagram(
podcast = podcastName,
episode = episodeName,
action = if (streaming) PodcastBoostagram.ACTION_STREAM else PodcastBoostagram.ACTION_BOOST,
appName = "Amethyst",
valueMsatTotal = totalMilliSats,
senderName = account.userProfile().toBestDisplayName(),
)
V4VPaymentHandler(account).pay(
value = value,
totalMilliSats = totalMilliSats,
boostagram = boostagram,
zappedNote = zappedNote,
context = context,
asZap = !streaming,
zapType = LnZapEvent.ZapType.PUBLIC,
okHttpClient = httpClientBuilder::okHttpClientForMoney,
onError = { title, message ->
if (!streaming) toastManager.toast(title, message)
},
onProgress = onProgress,
onPayInvoicesViaIntent = { invoices ->
if (!streaming) {
invoices.forEach { invoice ->
payViaIntent(invoice, context, onPaid = {}, onError = {
toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it)
})
}
}
},
)
}
/**
* Fire-and-forget NIP-61 nutzap from the zap picker. Picks a mint the
* recipient accepts (via their kind:10019) that we also have proofs at,
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent
import kotlinx.coroutines.flow.StateFlow
@Composable
@@ -66,6 +67,7 @@ fun ListOfBookmarkGroupsFeedView(
openOldBookmarks: () -> Unit,
openPinnedNotes: () -> Unit,
openRepositories: () -> Unit,
openPodcasts: () -> Unit,
onOpenItem: (String, BookmarkType) -> Unit,
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -99,6 +101,11 @@ fun ListOfBookmarkGroupsFeedView(
HorizontalDivider(thickness = DividerThickness)
}
item {
PodcastsBookmarkList(defaultBookmarks, openPodcasts)
HorizontalDivider(thickness = DividerThickness)
}
itemsIndexed(
bookmarkGroupFeedState,
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
@@ -249,6 +256,54 @@ fun RepositoriesBookmarkList(
)
}
@Composable
fun PodcastsBookmarkList(
defaultBookmarks: BookmarkListState,
openPodcasts: () -> Unit,
) {
val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle()
// Podcasts live in the same kind:10003 list as everything else, so count the podcast subset.
val podcastCount =
(bookmarkState.public + bookmarkState.private).count { isPodcastEvent(it.event) }
ListItem(
modifier = Modifier.clickable(onClick = openPodcasts),
headlineContent = {
Text(stringRes(R.string.podcast_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringRes(R.string.podcast_bookmarks_explainer),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Podcasts,
contentDescription = stringRes(R.string.bookmark_list_icon_label),
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
BookmarkMembershipStatusAndNumberDisplay(
modifier = Modifier.align(Alignment.CenterHorizontally),
postBookmarksSize = podcastCount,
articleBookmarksSize = 0,
)
}
},
)
}
@Composable
fun OldBookmarkList(
oldBookmarks: OldBookmarkListState,
@@ -66,6 +66,7 @@ fun ListOfBookmarkGroupsScreen(
openOldBookmarks = { nav.nav(Route.OldBookmarks) },
openPinnedNotes = { nav.nav(Route.PinnedNotes) },
openRepositories = { nav.nav(Route.BookmarkedRepositories) },
openPodcasts = { nav.nav(Route.BookmarkedPodcasts) },
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
openBookmarkGroup = { identifier, bookmarkType ->
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
@@ -110,6 +111,7 @@ fun ListOfBookmarkGroupsFeed(
openOldBookmarks: () -> Unit,
openPinnedNotes: () -> Unit,
openRepositories: () -> Unit,
openPodcasts: () -> Unit,
addBookmarkGroup: () -> Unit,
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -159,6 +161,7 @@ fun ListOfBookmarkGroupsFeed(
openOldBookmarks = openOldBookmarks,
openPinnedNotes = openPinnedNotes,
openRepositories = openRepositories,
openPodcasts = openPodcasts,
onOpenItem = openBookmarkGroup,
onRenameItem = renameBookmarkGroup,
onItemDescriptionChange = changeBookmarkGroupDescription,
@@ -0,0 +1,69 @@
/*
* 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.bookmarkgroups.podcasts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal.BookmarkPodcastsFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun BookmarkedPodcastsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val podcastsFeedViewModel: BookmarkPodcastsFeedViewModel =
viewModel(
key = "NostrBookmarkPodcastsFeedViewModel",
factory = BookmarkPodcastsFeedViewModel.Factory(accountViewModel.account),
)
val bookmarks by accountViewModel.account.bookmarkState.bookmarks
.collectAsStateWithLifecycle()
LaunchedEffect(bookmarks) {
podcastsFeedViewModel.invalidateData()
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarWithBackButton(stringRes(id = R.string.podcast_bookmarks), nav)
},
accountViewModel = accountViewModel,
) {
RefresheableFeedView(
podcastsFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -0,0 +1,48 @@
/*
* 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.bookmarkgroups.podcasts.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent
/**
* The podcast subset of the user's NIP-51 bookmark list (kind 10003): podcasts (shows and episodes)
* are bookmarked into the same general list as everything else, so this filter pulls just the
* podcast-typed notes ([isPodcastEvent]) back out — both public and private — newest first.
*/
class BookmarkPodcastsFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String =
account.bookmarkState.bookmarks.value
.hashCode()
.toString()
override fun feed(): List<Note> {
val bookmarks = account.bookmarkState.bookmarks.value
return (bookmarks.public + bookmarks.private)
.filter { isPodcastEvent(it.event) }
.distinct()
.sortedByDescending { it.createdAt() ?: 0L }
}
}
@@ -0,0 +1,39 @@
/*
* 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.bookmarkgroups.podcasts.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
@Stable
class BookmarkPodcastsFeedViewModel(
val account: Account,
) : AndroidFeedViewModel(BookmarkPodcastsFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = BookmarkPodcastsFeedViewModel(account) as T
}
}
@@ -0,0 +1,143 @@
/*
* 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
import androidx.compose.foundation.clickable
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
/**
* Renders a NIP-F4 podcast's claimed authors (`kind:10154` `p` tags, each a pubkey + role). The
* claims are unverified — the show can name anyone — so each author is cross-checked against their
* own counter-claim ([AuthoredPodcastsEvent], `kind:10064`): a verified check appears only when that
* author's 10064 actually lists this podcast's pubkey. The 10064 is fetched + observed lazily via
* [observeNoteEvent], so it arrives and flips the badge without extra wiring.
*/
@Composable
fun PodcastAuthors(
podcastPubkey: HexKey,
authors: List<AuthorTag>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
authors.forEach { author ->
PodcastAuthorRow(podcastPubkey, author, accountViewModel, nav)
}
}
}
@Composable
private fun PodcastAuthorRow(
podcastPubkey: HexKey,
author: AuthorTag,
accountViewModel: AccountViewModel,
nav: INav,
) {
var user by remember(author.pubKey) { mutableStateOf(accountViewModel.getUserIfExists(author.pubKey)) }
if (user == null) {
LaunchedEffect(author.pubKey) {
user = accountViewModel.checkGetOrCreateUser(author.pubKey)
}
}
val authoredNote =
remember(author.pubKey) {
LocalCache.getOrCreateAddressableNote(AuthoredPodcastsEvent.createAddress(author.pubKey))
}
val authored by observeNoteEvent<AuthoredPodcastsEvent>(authoredNote, accountViewModel)
val verified = authored?.authors(podcastPubkey) == true
val loadedUser = user ?: return
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(routeFor(loadedUser)) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClickableUserPicture(loadedUser, 28.dp, accountViewModel)
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(loadedUser, accountViewModel = accountViewModel)
author.role?.let {
Text(
text = roleLabel(it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
)
}
}
if (verified) {
Icon(
symbol = MaterialSymbols.CheckCircle,
contentDescription = stringRes(R.string.podcast_author_verified),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun roleLabel(role: String): String =
when (role) {
AuthorTag.ROLE_HOST -> stringRes(R.string.podcast_role_host)
AuthorTag.ROLE_COHOST -> stringRes(R.string.podcast_role_cohost)
AuthorTag.ROLE_EDITOR -> stringRes(R.string.podcast_role_editor)
else -> role
}
@@ -36,15 +36,18 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp))
@@ -59,15 +62,28 @@ fun PodcastEpisodeListItem(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastEpisodeEvent ?: return
val noteEvent = note.event ?: return
// Both kind 54 (NIP-F4) and kind 30054 (Podcasting 2.0) episodes implement PodcastEpisode.
val episode = noteEvent as? PodcastEpisode ?: return
val title = remember(noteEvent) { noteEvent.title() }
val description = remember(noteEvent) { noteEvent.description() }
val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() }
val image = remember(noteEvent) { noteEvent.image() }
val title = remember(noteEvent) { episode.episodeTitle() }
val description = remember(noteEvent) { episode.episodeDescription() }
// Prefer audio; fall back to a video source so video-only episodes still play inline.
val media = remember(noteEvent) { episode.episodeAudio().firstOrNull() ?: episode.episodeVideo() }
val image = remember(noteEvent) { episode.episodeImage() }
val season = remember(noteEvent) { episode.episodeSeason() }
val episodeNumber = remember(noteEvent) { episode.episodeNumber() }
val context = LocalContext.current
val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") }
val seasonEpisodeLabel =
when {
season != null && episodeNumber != null -> stringRes(R.string.podcast_season_episode, season, episodeNumber)
episodeNumber != null -> stringRes(R.string.podcast_episode_number, episodeNumber)
season != null -> stringRes(R.string.podcast_season, season)
else -> null
}
val subtitle = listOfNotNull(seasonEpisodeLabel, dateStr.takeIf { it.isNotBlank() }).joinToString(" · ")
Column(
modifier =
@@ -77,7 +93,7 @@ fun PodcastEpisodeListItem(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
dateStr.takeIf { it.isNotBlank() }?.let {
subtitle.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
@@ -107,7 +123,7 @@ fun PodcastEpisodeListItem(
)
}
firstAudio?.let { audio ->
media?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
@@ -117,5 +133,16 @@ fun PodcastEpisodeListItem(
accountViewModel = accountViewModel,
)
}
// Standard engagement row per episode (comment / zap / react) — same as every other note.
// addPadding = false since the row already sits inside this item's horizontal padding.
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = false,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -62,9 +62,10 @@ private fun PodcastEpisodesTopNavFilterBar(
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
// Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3
// Follows, Around Me, Global, people lists, interest sets, mute list.
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
// Same content-style catalog as Music — All Follows, Your Follows, kind3 Follows,
// Around Me, Global, Mine (the user's own published shows/episodes), people lists,
// interest sets, mute list.
val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
@@ -44,32 +44,43 @@ import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.types.PodcastCoverCard
import com.vitorpamplona.amethyst.ui.note.types.PodcastPeople
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.podcasts.PodcastShow
/**
* Hero header for a single podcast screen: large cover art, title, websites and the show
* description (rich text + translation, same as a profile's About), followed by the
* "Episodes (N)" section divider that the episode rows hang under.
*
* Spec-neutral: [show] is either a NIP-F4 [PodcastMetadataEvent] (kind 10154) or a Podcasting-2.0
* show (kind 30078, `d=podcast-metadata`), both adapting to the shared [PodcastShow]. The claimed-
* author verification row is NIP-F4 only (its `p`-tag claims + kind:10064 counter-claims), so it's
* shown only when the underlying event is a [PodcastMetadataEvent].
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PodcastHeader(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
episodeCount: Int?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val title = remember(metadataEvent) { metadataEvent?.title() }
val image = remember(metadataEvent) { metadataEvent?.image() }
val description = remember(metadataEvent) { metadataEvent?.description() }
val websites = remember(metadataEvent) { metadataEvent?.websites() ?: emptyList() }
val tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val title = remember(show) { show?.showTitle() }
val image = remember(show) { show?.showImage() }
val description = remember(show) { show?.showDescription() }
val websites = remember(show) { show?.showWebsites() ?: emptyList() }
val f4 = show as? PodcastMetadataEvent
val claimedAuthors = remember(f4) { f4?.claimedAuthors() ?: emptyList() }
val podcastPubkey = remember(f4) { f4?.pubKey }
val tags = remember(metadataNote) { metadataNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column(Modifier.fillMaxWidth()) {
PodcastCoverCard(image, metadataNote, accountViewModel)
@@ -122,11 +133,37 @@ fun PodcastHeader(
nav = nav,
)
}
if (claimedAuthors.isNotEmpty() && podcastPubkey != null) {
PodcastAuthors(podcastPubkey, claimedAuthors, accountViewModel, nav)
}
val persons = remember(show) { show?.showPersons() ?: emptyList() }
PodcastPeople(persons, accountViewModel, nav)
}
// Standard engagement row for the show itself (comment / zap / react), like any other
// content detail. Only shown once the show event resolves so it acts on a real note.
if (show != null) {
HorizontalDivider(thickness = DividerThickness)
ReactionsRow(
baseNote = metadataNote,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
PodcastTopSupporters(metadataNote, accountViewModel, nav)
}
// Only render once episodes have actually loaded — avoids flashing "0 episodes"
// under the cover while the relay request is still in flight.
episodeCount?.let { count ->
HorizontalDivider(thickness = DividerThickness)
Text(
text = pluralStringResource(R.plurals.podcast_episode_count, count, count),
style = MaterialTheme.typography.titleMedium,
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@@ -53,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.types.PodcastBookmarkButton
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel
@@ -60,8 +64,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastShow
@Composable
fun PodcastScreen(
@@ -70,10 +81,18 @@ fun PodcastScreen(
nav: INav,
) {
val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return
val metadataNote =
// A show is either NIP-F4 (kind 10154) or Podcasting-2.0 (kind 30078, d=podcast-metadata).
// Resolve both addresses; whichever has an event is this podcast's metadata.
val f4Note =
remember(pubkey) {
LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey))
}
val p20Note =
remember(pubkey) {
LocalCache.getOrCreateAddressableNote(
Address(AppSpecificDataEvent.KIND, pubkey, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG),
)
}
val feedViewModel: OnePodcastFeedViewModel =
viewModel(
@@ -81,23 +100,27 @@ fun PodcastScreen(
factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account),
)
PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav)
PodcastScreen(podcast, f4Note, p20Note, feedViewModel, accountViewModel, nav)
}
@Composable
fun PodcastScreen(
podcast: User,
metadataNote: Note,
f4Note: Note,
p20Note: Note,
feedViewModel: OnePodcastFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
// Fetches both the show metadata (kind 10154) and every episode (kind 54) authored by
// this podcast's key from its outbox relays.
// Fetches the show metadata (NIP-F4 kind 10154 / Podcasting-2.0 kind 30078) and every episode
// (kind 54 / kind 30054) + trailer authored by this podcast's key from its outbox relays.
OnePodcastFilterAssemblerSubscription(podcast, accountViewModel)
val metadataEvent by observeNoteEvent<PodcastMetadataEvent>(metadataNote, accountViewModel)
val f4Event by observeNoteEvent<PodcastMetadataEvent>(f4Note, accountViewModel)
val p20Event by observeNoteEvent<AppSpecificDataEvent>(p20Note, accountViewModel)
val show: PodcastShow? = remember(f4Event, p20Event) { resolvePodcastShow(f4Event) ?: resolvePodcastShow(p20Event) }
val metadataNote = if (f4Event != null) f4Note else p20Note
DisappearingScaffold(
isInvertedLayout = false,
@@ -105,12 +128,23 @@ fun PodcastScreen(
TopBarExtensibleWithBackButton(
title = {
Text(
text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts),
text = show?.showTitle() ?: stringRes(R.string.route_podcasts),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
},
actions = {
// Only offer these once the show metadata has loaded — there must be a resolved
// event to bookmark / act on. The bookmark button reflects bookmarked state; the
// 3-dot button is the standard NoteCompose options menu, sitting to its right.
if (show != null) {
PodcastBookmarkButton(metadataNote, accountViewModel)
Spacer(Modifier.width(Size10dp))
MoreOptionsButton(metadataNote, accountViewModel = accountViewModel, nav = nav)
Spacer(Modifier.width(Size10dp))
}
},
popBack = nav::popBack,
)
},
@@ -120,7 +154,7 @@ fun PodcastScreen(
SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState ->
PodcastScreenBody(
metadataNote = metadataNote,
metadataEvent = metadataEvent,
show = show,
feedViewModel = feedViewModel,
listState = listState,
accountViewModel = accountViewModel,
@@ -134,7 +168,7 @@ fun PodcastScreen(
@Composable
private fun PodcastScreenBody(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
feedViewModel: OnePodcastFeedViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
@@ -144,20 +178,20 @@ private fun PodcastScreenBody(
when (val state = feedState) {
is FeedState.Loaded ->
PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav)
PodcastEpisodesList(metadataNote, show, state, listState, accountViewModel, nav)
is FeedState.Empty ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
StatusText(stringRes(R.string.podcast_no_episodes))
}
is FeedState.FeedError ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
FeedError(state.errorMessage) { feedViewModel.invalidateData() }
}
is FeedState.Loading ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@@ -168,7 +202,7 @@ private fun PodcastScreenBody(
@Composable
private fun PodcastEpisodesList(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
@@ -181,15 +215,21 @@ private fun PodcastEpisodesList(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav)
// The list mixes in trailers; the header count should reflect episodes only.
val episodeCount = items.list.count { it.event !is Podcasting20TrailerEvent }
PodcastHeader(metadataNote, show, episodeCount, accountViewModel, nav)
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, _ -> "episode" },
) { index, episode ->
PodcastEpisodeListItem(episode, accountViewModel, nav)
contentType = { _, item -> if (item.event is Podcasting20TrailerEvent) "trailer" else "episode" },
) { index, item ->
if (item.event is Podcasting20TrailerEvent) {
PodcastTrailerListItem(item, accountViewModel, nav)
} else {
PodcastEpisodeListItem(item, accountViewModel, nav)
}
if (index < items.list.lastIndex) {
HorizontalDivider(thickness = DividerThickness)
@@ -201,7 +241,7 @@ private fun PodcastEpisodesList(
@Composable
private fun PodcastHeaderWithStatus(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
@@ -212,7 +252,7 @@ private fun PodcastHeaderWithStatus(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav)
PodcastHeader(metadataNote, show, null, accountViewModel, nav)
}
item("status") { status() }
}
@@ -0,0 +1,188 @@
/*
* 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.commons.nip53LiveActivities.LiveActivityTopZappersAggregator
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import java.math.BigDecimal
private val Gold = Color(0xFFFFC300)
private val Silver = Color(0xFFB0B7C0)
private val Bronze = Color(0xFFCD7F32)
/**
* "Top Supporters" leaderboard for a podcast show: aggregates the zaps on the show note into a
* sats-ranked list (reusing [LiveActivityTopZappersAggregator], the same engine as the live-stream
* leaderboard), top 3 flagged with gold/silver/bronze medals. Renders nothing until there's at least
* one zap. Matches PodStr's ZapLeaderboard, Nostr-native.
*/
@Composable
fun PodcastTopSupporters(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val zapState by observeNoteZaps(note, accountViewModel)
val entries =
remember(zapState) {
val contributions =
note.zaps.mapNotNull { (_, receiptNote) ->
val receipt = receiptNote?.event as? LnZapEvent ?: return@mapNotNull null
val request = receipt.zapRequest ?: return@mapNotNull null
val sats = receipt.amount()?.toLong() ?: return@mapNotNull null
// Anon/private zaps carry an `anon` tag; collapse them into the shared bucket.
val isAnon = request.tags.any { it.isNotEmpty() && it[0] == "anon" }
ZapContribution(receiptNote.idHex, request.pubKey, isAnon, sats)
}
LiveActivityTopZappersAggregator.aggregate(contributions)
}
if (entries.isEmpty()) return
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringRes(R.string.podcast_top_supporters),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
entries.forEachIndexed { index, entry ->
SupporterRow(index, entry, accountViewModel, nav)
}
}
}
@Composable
private fun SupporterRow(
index: Int,
entry: TopZapperEntry,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
RankBadge(index)
if (entry.isAnonymous) {
Text(
text = stringRes(R.string.chat_zap_anonymous),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
} else {
LoadUser(entry.bucketKey, accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(user, Size35dp, accountViewModel, onClick = { nav.nav(routeFor(it)) })
UsernameDisplay(user, Modifier.weight(1f), accountViewModel = accountViewModel)
} else {
Text(
text = "",
modifier = Modifier.weight(1f),
)
}
}
}
ZapIcon(Size16Modifier, BitcoinOrange)
Text(
text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
}
}
/** Gold/silver/bronze medal for the top 3, plain "#N" for the rest. */
@Composable
private fun RankBadge(index: Int) {
val medal =
when (index) {
0 -> Gold
1 -> Silver
2 -> Bronze
else -> null
}
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (medal != null) {
Icon(
symbol = MaterialSymbols.MilitaryTech,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = medal,
)
} else {
Text(
text = "${index + 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
@@ -0,0 +1,133 @@
/*
* 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
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.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastAudio
private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp))
/**
* A Podcasting-2.0 trailer (kind 30055) as a compact row, used on a podcast's show page and as
* the inline renderer in [com.vitorpamplona.amethyst.ui.note.NoteCompose]. A "Trailer" badge (with
* the season, when present) distinguishes it from episode rows; the media plays through the same
* player as episodes via the spec-neutral [PodcastAudio].
*/
@Composable
fun PodcastTrailerListItem(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? Podcasting20TrailerEvent ?: return
val title = remember(noteEvent) { noteEvent.title() }
val season = remember(noteEvent) { noteEvent.season() }
val media =
remember(noteEvent) {
noteEvent.url()?.let { PodcastAudio(it, noteEvent.mimeType()) }
}
val context = LocalContext.current
val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringRes(R.string.podcast_trailer),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
season?.let {
Text(
text = stringRes(R.string.podcast_trailer_season, it),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
dateStr.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
media?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
title = title,
image = null,
borderModifier = PLAYER_SHAPE,
accountViewModel = accountViewModel,
)
}
}
}
@@ -20,11 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
@@ -33,12 +40,14 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
@@ -82,6 +91,21 @@ fun PodcastsScreen(
}
}
},
floatingButton = {
FabBottomBarPadded(nav) {
FloatingActionButton(
onClick = { nav.nav(Route.PodcastAuthoring) },
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Mic,
contentDescription = stringRes(R.string.podcast_your_podcast),
tint = Color.White,
)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(podcastsFeedContentState, true) {
@@ -62,7 +62,7 @@ private fun PodcastsTopNavFilterBar(
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
@@ -0,0 +1,212 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.persistentListOf
/**
* Editor for the creator's Podcasting-2.0 show metadata (`kind:30078`, `d="podcast-metadata"`).
* There is one show per account, so this is always create-or-edit of the same event. Cover upload
* plus the channel fields; explicit / complete / locked as switches; episodic vs serial as a toggle.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPodcastShowScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: EditPodcastShowViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
var wantsToPickCover by remember { mutableStateOf(false) }
if (wantsToPickCover) {
GallerySelectSingle(
onImageUri = { picked ->
wantsToPickCover = false
vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf())
},
)
}
val isBusy = vm.isSending.value
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
Scaffold(
topBar = {
SendingTopBar(
titleRes = R.string.podcast_edit_show,
onCancel = { nav.popBack() },
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
CoverImagePicker(
cover = vm.coverMedia.value,
existingUrl = vm.coverUrl.value,
onPick = { wantsToPickCover = true },
onDelete = { vm.clearPickedCover() },
accountViewModel = accountViewModel,
enabled = !isBusy,
ctaRes = R.string.podcast_show_cover_cta,
hintRes = R.string.podcast_show_cover_hint,
)
Field(vm.title, R.string.podcast_show_title_label, R.string.podcast_show_title_placeholder, isError = vm.title.value.isBlank())
OutlinedTextField(
value = vm.description.value,
onValueChange = { vm.description.value = it },
label = { Text(stringRes(R.string.podcast_show_description_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
Field(vm.author, R.string.podcast_show_author_label, null, capitalization = KeyboardCapitalization.Words)
Field(vm.email, R.string.podcast_show_email_label, null, keyboardType = KeyboardType.Email)
Field(vm.website, R.string.podcast_show_website_label, null, keyboardType = KeyboardType.Uri)
Field(vm.categories, R.string.podcast_show_categories_label, R.string.podcast_show_categories_placeholder)
Field(vm.funding, R.string.podcast_show_funding_label, R.string.podcast_show_funding_placeholder, keyboardType = KeyboardType.Uri)
Field(vm.language, R.string.podcast_show_language_label, R.string.podcast_show_language_placeholder)
Field(vm.copyright, R.string.podcast_show_copyright_label, null)
// Episodic vs serial.
Text(
text = stringRes(R.string.podcast_show_type_label),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 4.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = vm.type.value == "episodic" || vm.type.value.isBlank(),
onClick = { vm.type.value = "episodic" },
label = { Text(stringRes(R.string.podcast_show_type_episodic)) },
)
FilterChip(
selected = vm.type.value == "serial",
onClick = { vm.type.value = "serial" },
label = { Text(stringRes(R.string.podcast_show_type_serial)) },
)
}
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, accountViewModel)
}
}
}
@Composable
private fun Field(
state: androidx.compose.runtime.MutableState<String>,
labelRes: Int,
placeholderRes: Int?,
isError: Boolean = false,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
keyboardType: KeyboardType = KeyboardType.Text,
) {
OutlinedTextField(
value = state.value,
onValueChange = { state.value = it },
label = { Text(stringRes(labelRes)) },
placeholder = placeholderRes?.let { { Text(stringRes(it)) } },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = isError,
keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType),
)
}
@Composable
private fun SwitchRow(
label: String,
checked: Boolean,
onChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@@ -0,0 +1,247 @@
/*
* 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 android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
/**
* Editor for a creator's Podcasting-2.0 show metadata — the single replaceable `kind:30078`
* (`d="podcast-metadata"`) event whose JSON body holds the channel-level fields. There is one per
* account, so this is always an edit-or-create of the same address; saving replaces it in place.
*
* Mirrors the profile-metadata editor: text fields + a cover upload, no audio. Any value-for-value
* block already on the event is preserved across save (the splits editor is separate).
*/
class EditPodcastShowViewModel : ViewModel() {
private lateinit var account: Account
val title = mutableStateOf("")
val description = mutableStateOf("")
val author = mutableStateOf("")
val email = mutableStateOf("")
val coverUrl = mutableStateOf("")
val website = mutableStateOf("")
val language = mutableStateOf("")
val categories = mutableStateOf("")
val funding = mutableStateOf("")
val copyright = mutableStateOf("")
/** "episodic" or "serial" (Podcasting 2.0), or blank for unset. */
val type = mutableStateOf("")
val explicit = mutableStateOf(false)
val complete = mutableStateOf(false)
val locked = mutableStateOf(false)
val isSending = mutableStateOf(false)
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
val coverMedia = mutableStateOf<MultiOrchestrator?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(null)
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 hasExisting = false
fun init(accountViewModel: AccountViewModel) {
if (::account.isInitialized) return
this.account = accountViewModel.account
this.selectedServer.value = account.settings.defaultFileServer
this.stripMetadata.value = account.settings.stripLocationOnUpload
val address = Address(AppSpecificDataEvent.KIND, account.userProfile().pubkeyHex, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
val existing = (LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) }
if (existing != null) {
hasExisting = true
preservedGuid = existing.guid()
splitEditor.load(existing.showValue())
title.value = existing.showTitle().orEmpty()
description.value = existing.showDescription().orEmpty()
author.value = existing.showAuthor().orEmpty()
email.value = existing.email().orEmpty()
coverUrl.value = existing.showImage().orEmpty()
website.value = existing.showWebsites().firstOrNull().orEmpty()
language.value = existing.language().orEmpty()
categories.value = existing.showCategories().joinToString(", ")
funding.value = existing.showFundingUrls().joinToString(", ")
copyright.value = existing.showCopyright().orEmpty()
type.value = existing.type().orEmpty()
explicit.value = existing.showIsExplicit()
complete.value = existing.showIsComplete()
locked.value = existing.isLocked()
}
}
fun setPickedCover(uris: ImmutableList<SelectedMedia>) {
coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
}
fun clearPickedCover() {
coverMedia.value = null
coverUrl.value = ""
}
fun isValid(): Boolean = title.value.isNotBlank()
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
if (isSending.value) return
val coverOrch = coverMedia.value
val server = selectedServer.value
if (coverOrch != null && server == null) {
accountViewModel.toastManager.toast(
"No upload server selected",
"Pick a media server in settings before uploading.",
)
return
}
val snapshot =
Snapshot(
content =
Podcasting20PodcastMetadata.Content(
title = title.value.trim(),
description = description.value.trim().ifBlank { null },
author = author.value.trim().ifBlank { null },
email = email.value.trim().ifBlank { null },
image = coverUrl.value.trim().ifBlank { null },
language = language.value.trim().ifBlank { null },
categories = PodcastComposerMedia.parseCsv(categories.value),
explicit = explicit.value.takeIf { it },
website = website.value.trim().ifBlank { null },
copyright = copyright.value.trim().ifBlank { null },
funding = PodcastComposerMedia.parseCsv(funding.value),
locked = locked.value.takeIf { it },
type = type.value.trim().ifBlank { null },
complete = complete.value.takeIf { it },
guid = preservedGuid,
value = splitEditor.toPodcastValue(),
),
coverOrchestrator = coverOrch,
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
)
isSending.value = true
accountViewModel.launchSigner {
try {
val newCoverUrl =
snapshot.coverOrchestrator?.let {
PodcastComposerMedia.upload(
orchestrator = it,
kind = "cover",
account = account,
server = snapshot.server!!,
quality = snapshot.quality,
stripMetadata = snapshot.stripMetadata,
alt = snapshot.content.title,
context = snapshot.appContext,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
}
val finalContent = newCoverUrl?.let { snapshot.content.copyWithImage(it) } ?: snapshot.content
account.signAndComputeBroadcast(Podcasting20PodcastMetadata.build(finalContent))
if (snapshot.coverOrchestrator != null) {
account.settings.changeDefaultFileServer(snapshot.server!!)
account.settings.changeStripLocationOnUpload(snapshot.stripMetadata)
}
withContext(Dispatchers.Main.immediate) {
coverMedia.value = null
if (newCoverUrl != null) coverUrl.value = newCoverUrl
}
_completionEvents.tryEmit(Unit)
} catch (t: Throwable) {
accountViewModel.toastManager.toast(
"Failed to save podcast",
t.message ?: t.javaClass.simpleName,
)
} finally {
withContext(Dispatchers.Main.immediate) { isSending.value = false }
}
}
}
private class Snapshot(
val content: Podcasting20PodcastMetadata.Content,
val coverOrchestrator: MultiOrchestrator?,
val server: ServerName?,
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
)
}
/** Copies a metadata content with a new image URL (Content has no copy() — it's a plain class). */
private fun Podcasting20PodcastMetadata.Content.copyWithImage(image: String): Podcasting20PodcastMetadata.Content =
Podcasting20PodcastMetadata.Content(
title = title,
description = description,
author = author,
email = email,
image = image,
language = language,
categories = categories,
explicit = explicit,
website = website,
copyright = copyright,
funding = funding,
locked = locked,
type = type,
complete = complete,
guid = guid,
value = value,
)
@@ -0,0 +1,374 @@
/*
* 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 android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
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.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.persistentListOf
/**
* Composer for a Podcasting-2.0 episode. Cover art + audio file upload at the top (or paste URLs),
* the core fields (title, summary, duration), and a collapsible "More details" section for the
* Podcasting-2.0 extras (season/number, video, transcript, chapters, topics). Create + edit + delete.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewPodcastEpisodeScreen(
editDTag: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: NewPodcastEpisodeViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel, editDTag) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
var wantsToPickCover by remember { mutableStateOf(false) }
if (wantsToPickCover) {
GallerySelectSingle(
onImageUri = { picked ->
wantsToPickCover = false
vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf())
},
)
}
var wantsToPickAudio by remember { mutableStateOf(false) }
if (wantsToPickAudio) {
AudioFileSelect { picked ->
wantsToPickAudio = false
vm.setPickedAudio(context, picked)
}
}
val isBusy = vm.isSending.value
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
Scaffold(
topBar = {
SendingTopBar(
titleRes = if (vm.isEditing) R.string.podcast_edit_episode else R.string.podcast_new_episode,
onCancel = { nav.popBack() },
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
CoverImagePicker(
cover = vm.coverMedia.value,
existingUrl = vm.coverUrl.value,
onPick = { wantsToPickCover = true },
onDelete = { vm.clearPickedCover() },
accountViewModel = accountViewModel,
enabled = !isBusy,
ctaRes = R.string.podcast_cover_upload_cta,
hintRes = R.string.podcast_cover_upload_hint,
)
AudioFilePickerRow(
pickedName = vm.pickedAudioName.value,
onPick = { wantsToPickAudio = true },
onClear = { vm.clearPickedAudio() },
enabled = !isBusy,
)
OutlinedTextField(
value = vm.title.value,
onValueChange = { vm.title.value = it },
label = { Text(stringRes(R.string.podcast_episode_title_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_title_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
isError = vm.title.value.isBlank(),
)
OutlinedTextField(
value = vm.description.value,
onValueChange = { vm.description.value = it },
label = { Text(stringRes(R.string.podcast_episode_summary_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
OutlinedTextField(
value = vm.durationSeconds.value,
onValueChange = { input -> vm.durationSeconds.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_duration_label)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
var showMore by rememberSaveable { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().clickable { showMore = !showMore }.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.podcast_episode_more_details),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Icon(
symbol = if (showMore) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(22.dp),
)
}
if (showMore) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = vm.season.value,
onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_season_label)) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = vm.episodeNumber.value,
onValueChange = { input -> vm.episodeNumber.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_number_label)) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
UrlField(vm.videoUrl, R.string.podcast_episode_video_label)
UrlField(vm.transcriptUrl, R.string.podcast_episode_transcript_label)
UrlField(vm.chaptersUrl, R.string.podcast_episode_chapters_label)
OutlinedTextField(
value = vm.topics.value,
onValueChange = { vm.topics.value = it },
label = { Text(stringRes(R.string.podcast_episode_topics_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_topics_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = vm.audioUrl.value,
onValueChange = { vm.audioUrl.value = it },
label = { Text(stringRes(R.string.podcast_episode_audio_url_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
// Episode-level V4V override; leave empty to inherit the show's split.
V4VSplitEditor(vm.splitEditor, accountViewModel)
}
if (vm.isEditing) {
HorizontalDivider()
DeleteEpisodeRow(vm = vm, onDeleted = { nav.popBack() }, accountViewModel = accountViewModel)
}
}
}
}
@Composable
private fun UrlField(
state: androidx.compose.runtime.MutableState<String>,
labelRes: Int,
) {
OutlinedTextField(
value = state.value,
onValueChange = { state.value = it },
label = { Text(stringRes(labelRes)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
}
@Composable
private fun AudioFilePickerRow(
pickedName: String?,
onPick: () -> Unit,
onClear: () -> Unit,
enabled: Boolean,
) {
if (pickedName != null) {
Row(
modifier =
Modifier
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp))
.let { if (enabled) it.clickable(onClick = onPick) else it }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
text = stringRes(R.string.podcast_episode_audio_picked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (enabled) {
TextButton(onClick = onClear) { Text(stringRes(R.string.cancel)) }
}
}
} else {
UploadPlaceholder(
iconSymbol = MaterialSymbols.MusicNote,
ctaRes = R.string.podcast_episode_audio_upload_cta,
hintRes = R.string.podcast_episode_audio_upload_hint,
onClick = onPick,
aspectRatio = null,
enabled = enabled,
)
}
}
// Single audio file via OpenDocument restricted to audio MIME types.
@Composable
private fun AudioFileSelect(onAudioPicked: (SelectedMedia?) -> Unit) {
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? -> onAudioPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
)
LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*")) }
}
@Composable
private fun DeleteEpisodeRow(
vm: NewPodcastEpisodeViewModel,
onDeleted: () -> Unit,
accountViewModel: AccountViewModel,
) {
var confirming by rememberSaveable { mutableStateOf(false) }
OutlinedButton(
onClick = { confirming = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(text = stringRes(R.string.podcast_episode_delete))
}
if (confirming) {
AlertDialog(
onDismissRequest = { confirming = false },
title = { Text(stringRes(R.string.podcast_episode_delete)) },
text = { Text(stringRes(R.string.podcast_episode_delete_confirm)) },
confirmButton = {
TextButton(onClick = {
confirming = false
accountViewModel.launchSigner { if (vm.deleteLoaded()) onDeleted() }
}) {
Text(text = stringRes(R.string.podcast_episode_delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirming = false }) { Text(stringRes(R.string.cancel)) }
},
)
}
}
@@ -0,0 +1,335 @@
/*
* 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 android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastValue
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Composer for a Podcasting-2.0 episode (`kind:30054`, addressable). Closely mirrors the music-track
* composer: the user picks a cover image and an audio file, Save uploads them through Blossom/NIP-96,
* then publishes the addressable event with the returned URLs. Power users can paste raw URLs instead.
*
* Edit mode (`editDTag` resolves from LocalCache) re-publishes under the same `d` tag so it replaces
* the prior version in place; the original `pubdate` and any value-for-value block are preserved.
*/
class NewPodcastEpisodeViewModel : ViewModel() {
private lateinit var account: Account
val title = mutableStateOf("")
val description = mutableStateOf("")
val audioUrl = mutableStateOf("")
val coverUrl = mutableStateOf("")
val durationSeconds = mutableStateOf("")
val episodeNumber = mutableStateOf("")
val season = mutableStateOf("")
val videoUrl = mutableStateOf("")
val transcriptUrl = mutableStateOf("")
val chaptersUrl = mutableStateOf("")
val topics = mutableStateOf("")
val isSending = mutableStateOf(false)
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
val coverMedia = mutableStateOf<MultiOrchestrator?>(null)
val audioMedia = mutableStateOf<MultiOrchestrator?>(null)
val pickedAudioName = mutableStateOf<String?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(null)
val mediaQualitySlider = mutableStateOf(1)
val stripMetadata = mutableStateOf(true)
private var dTag: String? = null
private var loadedEvent: Podcasting20EpisodeEvent? = null
/** 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
val isEditing: Boolean
get() = loadedEvent != null
fun init(
accountViewModel: AccountViewModel,
editDTag: String?,
) {
if (::account.isInitialized) return
this.account = accountViewModel.account
this.selectedServer.value = account.settings.defaultFileServer
this.stripMetadata.value = account.settings.stripLocationOnUpload
if (editDTag != null) {
val address = Address(Podcasting20EpisodeEvent.KIND, account.userProfile().pubkeyHex, editDTag)
(LocalCache.addressables.get(address)?.event as? Podcasting20EpisodeEvent)?.let { existing ->
dTag = editDTag
loadedEvent = existing
preservedPubDate = existing.pubDate()
splitEditor.load(existing.value())
title.value = existing.title().orEmpty()
description.value = existing.description().orEmpty()
audioUrl.value =
existing
.audios()
.firstOrNull()
?.url
.orEmpty()
coverUrl.value = existing.image().orEmpty()
durationSeconds.value = existing.durationInSeconds()?.toString().orEmpty()
episodeNumber.value = existing.number()?.toString().orEmpty()
season.value = existing.season()?.toString().orEmpty()
videoUrl.value = existing.video()?.url.orEmpty()
transcriptUrl.value = existing.transcriptUrl().orEmpty()
chaptersUrl.value = existing.chaptersUrl().orEmpty()
topics.value = existing.topics().joinToString(", ")
}
}
}
fun setPickedCover(uris: ImmutableList<SelectedMedia>) {
coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
}
fun clearPickedCover() {
coverMedia.value = null
coverUrl.value = ""
}
fun setPickedAudio(
context: Context,
uri: SelectedMedia?,
) {
if (uri == null) {
audioMedia.value = null
pickedAudioName.value = null
return
}
audioMedia.value = MultiOrchestrator(persistentListOf(uri))
pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
val appContext = context.applicationContext
viewModelScope.launch(Dispatchers.IO) {
val probed = PodcastComposerMedia.probeAudio(appContext, uri.uri) ?: return@launch
withContext(Dispatchers.Main.immediate) {
probed.durationSeconds?.let { durationSeconds.value = it.toString() }
if (title.value.isBlank()) probed.title?.let { title.value = it }
}
}
}
fun clearPickedAudio() {
audioMedia.value = null
pickedAudioName.value = null
}
/** Valid with a title and a resolvable audio source (picked file or a pasted URL). */
fun isValid(): Boolean = title.value.isNotBlank() && (audioMedia.value != null || audioUrl.value.isNotBlank())
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
if (isSending.value) return
val server = selectedServer.value
if (server == null) {
accountViewModel.toastManager.toast(
"No upload server selected",
"Pick a media server in settings before uploading.",
)
return
}
val snapshot =
Snapshot(
title = title.value.trim(),
description = description.value.trim().ifBlank { null },
durationSeconds = durationSeconds.value.trim().toLongOrNull(),
episodeNumber = episodeNumber.value.trim().toIntOrNull(),
season = season.value.trim().toIntOrNull(),
videoUrl = videoUrl.value.trim().ifBlank { null },
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 },
existingAudioUrl = audioUrl.value.trim(),
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
)
isSending.value = true
accountViewModel.launchSigner {
try {
val (newCoverUrl, newAudioUrl) = performParallelUploads(snapshot)
val finalCoverUrl = newCoverUrl ?: snapshot.existingCoverUrl
val finalAudioUrl = newAudioUrl ?: snapshot.existingAudioUrl
if (finalAudioUrl.isBlank()) {
accountViewModel.toastManager.toast(
"Audio upload failed",
"No audio URL ended up available for the published episode.",
)
return@launchSigner
}
publishEpisode(snapshot, finalCoverUrl, finalAudioUrl)
account.settings.changeDefaultFileServer(snapshot.server)
account.settings.changeStripLocationOnUpload(snapshot.stripMetadata)
withContext(Dispatchers.Main.immediate) {
coverMedia.value = null
audioMedia.value = null
pickedAudioName.value = null
if (newCoverUrl != null) coverUrl.value = newCoverUrl
if (newAudioUrl != null) audioUrl.value = newAudioUrl
}
_completionEvents.tryEmit(Unit)
} catch (t: Throwable) {
accountViewModel.toastManager.toast(
"Failed to publish episode",
t.message ?: t.javaClass.simpleName,
)
} finally {
withContext(Dispatchers.Main.immediate) { isSending.value = false }
}
}
}
private class Snapshot(
val title: String,
val description: String?,
val durationSeconds: Long?,
val episodeNumber: Int?,
val season: Int?,
val videoUrl: String?,
val transcriptUrl: String?,
val chaptersUrl: String?,
val topics: List<String>,
val value: PodcastValue?,
val coverOrchestrator: MultiOrchestrator?,
val audioOrchestrator: MultiOrchestrator?,
val existingCoverUrl: String?,
val existingAudioUrl: String,
val server: ServerName,
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
)
private suspend fun performParallelUploads(snapshot: Snapshot): Pair<String?, String?> =
coroutineScope {
val deferreds =
listOf(
async { snapshot.coverOrchestrator?.let { uploadOne(it, "cover", snapshot) } },
async { snapshot.audioOrchestrator?.let { uploadOne(it, "audio", snapshot) } },
)
val results = deferreds.awaitAll()
results[0] to results[1]
}
private suspend fun uploadOne(
orchestrator: MultiOrchestrator,
kind: String,
snapshot: Snapshot,
): String =
PodcastComposerMedia.upload(
orchestrator = orchestrator,
kind = kind,
account = account,
server = snapshot.server,
quality = snapshot.quality,
stripMetadata = snapshot.stripMetadata,
alt = snapshot.title.ifBlank { null },
context = snapshot.appContext,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
private suspend fun publishEpisode(
snapshot: Snapshot,
coverUrl: String?,
audioUrl: String,
) {
// Audio/video MIME isn't tracked once uploaded (the URL is enough; the player sniffs the
// type). The summary lives in the `description` tag; `content` stays empty so the episode
// renderer doesn't show the same text twice (it renders the tag and the markdown body apart).
val template =
Podcasting20EpisodeEvent.build(
dTag = dTag ?: PodcastComposerMedia.generateDTag("episode"),
title = snapshot.title,
audios = listOf(PodcastAudio(audioUrl, null)),
pubdate = preservedPubDate ?: PodcastComposerMedia.rfc2822Now(),
description = snapshot.description,
image = coverUrl?.ifBlank { null },
durationInSeconds = snapshot.durationSeconds,
video = snapshot.videoUrl?.let { PodcastAudio(it, null) },
episodeNumber = snapshot.episodeNumber,
season = snapshot.season,
transcriptUrl = snapshot.transcriptUrl,
chaptersUrl = snapshot.chaptersUrl,
value = snapshot.value,
topics = snapshot.topics,
)
account.signAndComputeBroadcast(template)
}
suspend fun deleteLoaded(): Boolean {
val target = loadedEvent ?: return false
val note = LocalCache.getOrCreateAddressableNote(target.address())
account.delete(note)
return true
}
}
@@ -0,0 +1,204 @@
/*
* 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 android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
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.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder
import com.vitorpamplona.amethyst.ui.stringRes
/** Composer for a Podcasting-2.0 trailer (`kind:30055`): title, a short audio/video clip, and season. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewPodcastTrailerScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: NewPodcastTrailerViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
var wantsToPick by remember { mutableStateOf(false) }
if (wantsToPick) {
MediaFileSelect { picked ->
wantsToPick = false
vm.setPickedMedia(picked)
}
}
val isBusy = vm.isSending.value
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
Scaffold(
topBar = {
SendingTopBar(
titleRes = R.string.podcast_new_trailer,
onCancel = { nav.popBack() },
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
val pickedName = vm.pickedName.value
if (pickedName != null) {
Row(
modifier =
Modifier
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp))
.let { if (!isBusy) it.clickable { wantsToPick = true } else it }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
text = stringRes(R.string.podcast_episode_audio_picked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!isBusy) {
TextButton(onClick = { vm.clearPickedMedia() }) { Text(stringRes(R.string.cancel)) }
}
}
} else {
UploadPlaceholder(
iconSymbol = MaterialSymbols.MusicNote,
ctaRes = R.string.podcast_trailer_upload_cta,
hintRes = R.string.podcast_trailer_upload_hint,
onClick = { wantsToPick = true },
aspectRatio = null,
enabled = !isBusy,
)
}
OutlinedTextField(
value = vm.title.value,
onValueChange = { vm.title.value = it },
label = { Text(stringRes(R.string.podcast_episode_title_label)) },
placeholder = { Text(stringRes(R.string.podcast_trailer_title_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
isError = vm.title.value.isBlank(),
)
OutlinedTextField(
value = vm.url.value,
onValueChange = { vm.url.value = it },
label = { Text(stringRes(R.string.podcast_trailer_url_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
OutlinedTextField(
value = vm.season.value,
onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_season_label)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
}
}
// Audio or video file via OpenDocument.
@Composable
private fun MediaFileSelect(onPicked: (SelectedMedia?) -> Unit) {
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? -> onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
)
LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*", "video/*")) }
}
@@ -0,0 +1,163 @@
/*
* 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 android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
/**
* Composer for a Podcasting-2.0 trailer (`kind:30055`, addressable). A short preview clip: a title,
* one audio/video file (uploaded or pasted as a URL), and an optional season number. Always
* create-new — each trailer gets a fresh `d` tag.
*/
class NewPodcastTrailerViewModel : ViewModel() {
private lateinit var account: Account
val title = mutableStateOf("")
val url = mutableStateOf("")
val season = mutableStateOf("")
val pickedMimeType = mutableStateOf<String?>(null)
val isSending = mutableStateOf(false)
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
val media = mutableStateOf<MultiOrchestrator?>(null)
val pickedName = mutableStateOf<String?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(null)
val mediaQualitySlider = mutableStateOf(1)
val stripMetadata = mutableStateOf(true)
fun init(accountViewModel: AccountViewModel) {
if (::account.isInitialized) return
this.account = accountViewModel.account
this.selectedServer.value = account.settings.defaultFileServer
this.stripMetadata.value = account.settings.stripLocationOnUpload
}
fun setPickedMedia(uri: SelectedMedia?) {
if (uri == null) {
media.value = null
pickedName.value = null
pickedMimeType.value = null
return
}
media.value = MultiOrchestrator(persistentListOf(uri))
pickedName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
pickedMimeType.value = uri.mimeType
}
fun clearPickedMedia() {
media.value = null
pickedName.value = null
pickedMimeType.value = null
}
fun isValid(): Boolean = title.value.isNotBlank() && (media.value != null || url.value.isNotBlank())
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
if (isSending.value) return
val server = selectedServer.value
if (server == null) {
accountViewModel.toastManager.toast(
"No upload server selected",
"Pick a media server in settings before uploading.",
)
return
}
val titleSnap = title.value.trim()
val urlSnap = url.value.trim()
val seasonSnap = season.value.trim().toIntOrNull()
val mimeSnap = pickedMimeType.value
val mediaSnap = media.value
val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value)
val strip = stripMetadata.value
val appContext = context.applicationContext
isSending.value = true
accountViewModel.launchSigner {
try {
val finalUrl =
if (mediaSnap != null) {
PodcastComposerMedia.upload(
orchestrator = mediaSnap,
kind = "trailer",
account = account,
server = server,
quality = quality,
stripMetadata = strip,
alt = titleSnap.ifBlank { null },
context = appContext,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
} else {
urlSnap
}
if (finalUrl.isBlank()) {
accountViewModel.toastManager.toast("Trailer upload failed", "No URL was available for the trailer.")
return@launchSigner
}
val template =
Podcasting20TrailerEvent.build(
dTag = PodcastComposerMedia.generateDTag("trailer"),
title = titleSnap,
url = finalUrl,
pubdate = PodcastComposerMedia.rfc2822Now(),
mimeType = mimeSnap,
season = seasonSnap,
)
account.signAndComputeBroadcast(template)
account.settings.changeDefaultFileServer(server)
account.settings.changeStripLocationOnUpload(strip)
_completionEvents.tryEmit(Unit)
} catch (t: Throwable) {
accountViewModel.toastManager.toast("Failed to publish trailer", t.message ?: t.javaClass.simpleName)
} finally {
withContext(Dispatchers.Main.immediate) { isSending.value = false }
}
}
}
}
@@ -0,0 +1,319 @@
/*
* 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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import coil3.compose.rememberAsyncImagePainter
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.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
/**
* "Your podcast" — the authoring hub. Shows the creator's own Podcasting-2.0 show (or a create CTA),
* the new-episode / new-trailer / edit-show entry points, and lists the episodes and trailers the
* creator has already published (tap to edit). Data is read from [LocalCache] and refreshed each time
* the screen resumes, so it reflects anything just published from a composer.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PodcastAuthoringScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val me = accountViewModel.account.userProfile().pubkeyHex
// Keep a REQ open for the creator's own catalog while the hub is visible, so the lists below
// fill in from relays even when nothing was cached yet.
MyPodcastFilterAssemblerSubscription(accountViewModel)
// Re-scan LocalCache on each resume (returning from a composer) and whenever the creator's own
// podcast events arrive over the open REQ, so the lists stay current without a manual refresh.
var refresh by remember { mutableIntStateOf(0) }
LifecycleResumeEffect(Unit) {
refresh++
onPauseOrDispose { }
}
LaunchedEffect(me) {
LocalCache.live.newEventBundles.collect { bundle ->
val mine =
bundle.any {
val e = it.event
e?.pubKey == me &&
(
e is Podcasting20EpisodeEvent ||
e is Podcasting20TrailerEvent ||
(e is AppSpecificDataEvent && e.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
)
}
if (mine) refresh++
}
}
val show =
remember(refresh) {
val address = Address(AppSpecificDataEvent.KIND, me, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
(LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) }
}
val episodes =
remember(refresh) {
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is Podcasting20EpisodeEvent && e.pubKey == me
}.mapNotNull { it.event as? Podcasting20EpisodeEvent }
.sortedByDescending { it.createdAt }
}
val trailers =
remember(refresh) {
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is Podcasting20TrailerEvent && e.pubKey == me
}.mapNotNull { it.event as? Podcasting20TrailerEvent }
.sortedByDescending { it.createdAt }
}
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.podcast_your_podcast), nav) },
) { pad ->
LazyColumn(
modifier = Modifier.padding(pad).fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ShowHeaderCard(show, onEdit = { nav.nav(Route.EditPodcastShow) }) }
item {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = { nav.nav(Route.NewPodcastEpisode()) },
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(text = stringRes(R.string.podcast_new_episode), modifier = Modifier.padding(start = 6.dp))
}
OutlinedButton(
onClick = { nav.nav(Route.NewPodcastTrailer) },
modifier = Modifier.weight(1f),
) {
Text(text = stringRes(R.string.podcast_new_trailer))
}
}
}
if (episodes.isNotEmpty()) {
item { SectionHeader(pluralStringResource(R.plurals.podcast_episode_count, episodes.size, episodes.size)) }
items(episodes, key = { it.id }) { ep ->
EpisodeRow(
title = ep.title() ?: stringRes(R.string.podcast_untitled),
subtitle = episodeSubtitle(ep),
onClick = { nav.nav(Route.NewPodcastEpisode(ep.dTag())) },
)
}
}
if (trailers.isNotEmpty()) {
item { SectionHeader(pluralStringResource(R.plurals.podcast_trailer_count, trailers.size, trailers.size)) }
items(trailers, key = { it.id }) { tr ->
EpisodeRow(
title = tr.title() ?: stringRes(R.string.podcast_untitled),
subtitle = tr.url(),
onClick = null,
)
}
}
if (episodes.isEmpty()) {
item {
Text(
text = stringRes(R.string.podcast_no_episodes_yet),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}
}
}
@Composable
private fun ShowHeaderCard(
show: Podcasting20PodcastMetadata?,
onEdit: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onEdit)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
val image = show?.showImage()
Box(
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
if (!image.isNullOrBlank()) {
Image(
painter = rememberAsyncImagePainter(model = image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize(),
)
} else {
Icon(symbol = MaterialSymbols.Podcasts, contentDescription = null, modifier = Modifier.size(30.dp))
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = show?.showTitle()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.podcast_create_your_show),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (show != null) stringRes(R.string.podcast_tap_to_edit_show) else stringRes(R.string.podcast_create_show_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(symbol = MaterialSymbols.Edit, contentDescription = null, modifier = Modifier.size(20.dp))
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp),
)
}
@Composable
private fun EpisodeRow(
title: String,
subtitle: String?,
onClick: (() -> Unit)?,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (onClick != null) {
Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, modifier = Modifier.size(20.dp), tint = Color.Gray)
}
}
}
private fun episodeSubtitle(ep: Podcasting20EpisodeEvent): String? {
val season = ep.season()
val number = ep.number()
return when {
season != null && number != null -> "S$season · E$number"
number != null -> "Ep $number"
season != null -> "Season $season"
else -> null
}
}
@@ -0,0 +1,131 @@
/*
* 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 android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
/**
* Shared upload + media-probe mechanics for the Podcasting-2.0 composers (episode, show, trailer).
* Mirrors the music-track composer's approach but kept in one place so all three authoring
* ViewModels resolve a picked file to a hosted URL the same way.
*/
object PodcastComposerMedia {
class UploadException(
kind: String,
details: String,
) : Exception("$kind upload failed: $details")
/**
* Uploads a single picked file (cover image or audio) through the user's media server and
* returns the hosted URL. Throws [UploadException] if the server rejects it or returns no URL.
*/
suspend fun upload(
orchestrator: MultiOrchestrator,
kind: String,
account: Account,
server: ServerName,
quality: CompressorQuality,
stripMetadata: Boolean,
alt: String?,
context: Context,
onStrippingFailed: suspend () -> Boolean,
): String {
val res =
orchestrator.upload(
alt = alt,
contentWarningReason = null,
mediaQuality = quality,
server = server,
account = account,
context = context,
useH265 = false,
stripMetadata = stripMetadata,
onStrippingFailed = onStrippingFailed,
)
if (!res.allGood) throw UploadException(kind, formatUploadErrors(res.errors, context))
return firstUploadedUrl(res.successful)
?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.")
}
private fun firstUploadedUrl(successful: List<UploadingState.Finished>): String? =
successful
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
?.url
private fun formatUploadErrors(
errors: List<UploadingState.Error>,
context: Context,
): String =
errors
.map { context.getString(it.errorResource, *it.params) }
.distinct()
.joinToString(".\n")
/** Audio metadata read off a picked file, used to auto-fill the composer. Any field may be null. */
class ProbedAudio(
val durationSeconds: Int?,
val title: String?,
)
/**
* Reads duration + title from a picked audio file via [MediaMetadataRetriever] (heavy — call off
* the main thread). Returns null if the provider rejects it or the file isn't a real container.
*/
fun probeAudio(
context: Context,
uri: Uri,
): ProbedAudio? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
ProbedAudio(
durationSeconds = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } },
title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null },
)
} catch (_: Exception) {
null
} finally {
retriever.release()
}
}
/** Current time as an RFC2822 date string (`Tue, 24 Jun 2025 12:00:00 GMT`) — the spec's `pubdate`. */
fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")))
/** A fresh, stable `d` tag for a new addressable episode/trailer. */
fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}"
/** Splits a comma-separated text field into a clean list (trimmed, no blanks). */
fun parseCsv(text: String): List<String> = text.split(',').map { it.trim() }.filter { it.isNotEmpty() }
}
@@ -0,0 +1,331 @@
/*
* 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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.commons.podcasts.RecipientDraft
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* Editor for a Podcasting-2.0 value-for-value split. Recipients are added the Amethyst-native way —
* search for a Nostr user and they're rendered with avatar + name, their lightning address resolved
* automatically — with a manual "add address" fallback for raw lightning addresses or node keysend
* destinations. 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].
*/
@Composable
fun V4VSplitEditor(
state: V4VSplitEditorState,
accountViewModel: AccountViewModel,
) {
val total = state.totalSplit()
val userSuggestions =
remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) }
var search by remember { mutableStateOf("") }
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 ->
if (draft.user.value != null) {
UserRecipientCard(draft, total, accountViewModel, onRemove = { state.remove(draft) })
} else {
ManualRecipientCard(draft, total, onRemove = { state.remove(draft) })
}
}
// Search a Nostr user to add (resolves their lightning address). Beautiful path.
OutlinedTextField(
value = search,
onValueChange = { newValue ->
search = newValue
if (newValue.length > 2) userSuggestions.processCurrentWord(newValue) else userSuggestions.reset()
},
label = { Text(stringRes(R.string.podcast_value_search_user)) },
placeholder = { Text(stringRes(R.string.podcast_value_search_user_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
if (search.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
val added = state.addUser(user)
if (!added) {
accountViewModel.toastManager.toast(
R.string.podcast_value_for_value,
R.string.podcast_value_user_no_lnaddress,
)
}
search = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
// Fallback for raw destinations (a node pubkey for keysend, or a non-Nostr lightning address).
TextButton(onClick = { state.addManual() }, modifier = Modifier.fillMaxWidth()) {
Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(text = stringRes(R.string.podcast_value_add_address), modifier = Modifier.padding(start = 6.dp))
}
}
}
@Composable
private fun RecipientShell(
total: Int,
draft: RecipientDraft,
onRemove: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
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) {
content()
Text(
text = stringRes(R.string.podcast_value_split_percent, percent),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
IconButton(onClick = onRemove) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(R.string.podcast_value_remove_recipient),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
WeightAndFeeRow(draft, weight)
}
}
@Composable
private fun UserRecipientCard(
draft: RecipientDraft,
total: Int,
accountViewModel: AccountViewModel,
onRemove: () -> Unit,
) {
val user = draft.user.value ?: return
RecipientShell(total, draft, onRemove) {
BaseUserPicture(user, Size40dp, accountViewModel = accountViewModel)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(user, accountViewModel = accountViewModel)
val lud = user.lnAddress()
Text(
text = lud ?: stringRes(R.string.podcast_value_user_no_lnaddress),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}
}
}
@Composable
private fun ManualRecipientCard(
draft: RecipientDraft,
total: Int,
onRemove: () -> Unit,
) {
val isNode by draft.isNode
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val weight =
draft.split.value
.trim()
.toIntOrNull() ?: 0
val percent = if (total > 0 && weight > 0) weight * 100 / total else 0
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(),
)
WeightAndFeeRow(draft, weight)
}
}
@Composable
private fun WeightAndFeeRow(
draft: RecipientDraft,
weight: Int,
) {
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)) },
)
}
}
@@ -23,15 +23,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
/**
* Every episode of a single podcast. Per NIP-F4 each podcast is its own keypair, so all
* episodes (kind 54) of a show are authored by [podcastPubkey]. Episodes are regular events,
* so they live in `LocalCache.notes`. Most-recent-first via [sortedByDefaultFeedOrder].
* Everything a single podcast (authored by [podcastPubkey]) publishes for its show page: NIP-F4
* episodes (kind 54, regular events in `LocalCache.notes`), Podcasting-2.0 episodes (kind 30054)
* and Podcasting-2.0 trailers (kind 30055, both addressable events in `LocalCache.addressables`).
* Episodes go through the shared [PodcastEpisode] interface; trailers are matched by their concrete
* type and rendered distinctly. In every model the show's pubkey authors its own content.
* Most-recent-first via [sortedByDefaultFeedOrder].
*/
class OnePodcastEpisodesFeedFilter(
val podcastPubkey: HexKey,
@@ -41,18 +47,27 @@ class OnePodcastEpisodesFeedFilter(
override fun feedKey(): String = "podcast-" + podcastPubkey
override fun feed(): List<Note> {
val notes =
val regular =
cache.notes.filterIntoSet { _, it ->
acceptableEvent(it)
}
return sort(notes)
val episodes =
cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it ->
acceptableEvent(it)
}
val trailers =
cache.addressables.filterIntoSet(Podcasting20TrailerEvent.KIND) { _, it ->
acceptableEvent(it)
}
return sort(regular + episodes + trailers)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { acceptableEvent(it) }
private fun acceptableEvent(note: Note): Boolean {
val noteEvent = note.event
return noteEvent is PodcastEpisodeEvent &&
val noteEvent = note.event ?: return false
val isShowContent = noteEvent is PodcastEpisode || noteEvent is Podcasting20TrailerEvent
return isShowContent &&
noteEvent.pubKey == podcastPubkey &&
!note.isHiddenFor(account.hiddenUsers.flow.value) &&
account.isAcceptable(note)
@@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
/**
* Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike
* music tracks/playlists which are addressable. Same follow-list + hidden/blocked
* gate as MusicTracksFeedFilter.
* Merges the two podcast-episode kinds into one list. NIP-F4 episodes (kind 54) are regular
* events in `LocalCache.notes`; Podcasting-2.0 episodes (kind 30054) are addressable events in
* `LocalCache.addressables`. Both implement [PodcastEpisode], so a single accept gate covers
* them. Same follow-list + hidden/blocked gate as MusicTracksFeedFilter.
*/
class PodcastEpisodesFeedFilter(
val account: Account,
@@ -54,11 +56,15 @@ class PodcastEpisodesFeedFilter(
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
val regular =
LocalCache.notes.filterIntoSet { _, it ->
accept(it, params)
}
return sort(notes)
val addressable =
LocalCache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it ->
accept(it, params)
}
return sort(regular + addressable)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
@@ -78,8 +84,8 @@ class PodcastEpisodesFeedFilter(
note: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = note.event
return noteEvent is PodcastEpisodeEvent &&
val noteEvent = note.event ?: return false
return noteEvent is PodcastEpisode &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
@@ -28,11 +28,15 @@ import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastShowEvent
/**
* Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in
* `LocalCache.addressables` — same shape as the music-playlists filter.
* Merges show-level podcast metadata from both drafts into one list. NIP-F4 shows (kind 10154)
* and Podcasting-2.0 shows (kind 30078 NIP-78 app-data with `d="podcast-metadata"`) are both
* replaceable, so they live in `LocalCache.addressables`. [isPodcastShowEvent] gates inclusion
* (the kind-30078 scan would otherwise see unrelated app-data, so the `d`-tag check matters).
*/
class PodcastsFeedFilter(
val account: Account,
@@ -53,11 +57,15 @@ class PodcastsFeedFilter(
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
val f4 =
LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it ->
accept(it, params)
}
return sort(notes)
val podcasting20 =
LocalCache.addressables.filterIntoSet(AppSpecificDataEvent.KIND) { _, it ->
accept(it, params)
}
return sort(f4 + podcasting20)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
@@ -77,8 +85,8 @@ class PodcastsFeedFilter(
note: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = note.event
return noteEvent is PodcastMetadataEvent &&
val noteEvent = note.event ?: return false
return isPodcastShowEvent(noteEvent) &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
@@ -0,0 +1,81 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
/**
* REQ for the logged-in creator's own Podcasting-2.0 catalog, so the authoring hub reliably shows
* their show, episodes and trailers even on a fresh install (rather than only what happens to be in
* [LocalCache]). Queried on the creator's own outbox relays.
*
* Two separate filters per relay, and they can't be merged:
* - episodes (kind 30054) + trailers (kind 30055) by author, with no tag constraint; and
* - the show metadata (kind 30078) constrained to `#d=["podcast-metadata"]`. That NIP-78 app-data
* kind is heavily overloaded, so without the `#d` constraint this REQ would pull every app's data
* (including the user's private settings) for the pubkey. The constraint must stay off the episodes
* filter, since each episode/trailer carries its own `d` tag — a combined `#d` would match nothing.
*/
fun filterMyPodcast(
user: User,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays =
user.outboxRelays()?.ifEmpty { null }
?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
val authors = listOf(user.pubkeyHex)
return relays.flatMap { relay ->
val sinceTime = since?.get(relay)?.time
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND),
since = sinceTime,
limit = 200,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
kinds = listOf(AppSpecificDataEvent.KIND),
tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)),
since = sinceTime,
limit = 10,
),
),
)
}
}
@@ -25,16 +25,22 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
// Per NIP-F4 a podcast is its own keypair: the show-level metadata (kind 10154) and every
// episode (kind 54) are authored by the same pubkey. So a single-podcast screen fetches
// both kinds from that one author.
// A single-podcast screen fetches everything authored by the show's pubkey, across both drafts:
// NIP-F4 metadata (kind 10154) + episodes (kind 54), and Podcasting-2.0 episodes (kind 30054) +
// trailers (kind 30055). In both models the show key authors its own episodes and trailers.
private val OnePodcastKinds =
listOf(
PodcastMetadataEvent.KIND,
PodcastEpisodeEvent.KIND,
Podcasting20EpisodeEvent.KIND,
Podcasting20TrailerEvent.KIND,
)
fun filterOnePodcast(
@@ -47,16 +53,31 @@ fun filterOnePodcast(
user.outboxRelays()?.ifEmpty { null }
?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = OnePodcastKinds,
authors = listOf(user.pubkeyHex),
limit = 500,
since = since?.get(relay)?.time,
),
return relays.flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = OnePodcastKinds,
authors = listOf(user.pubkeyHex),
limit = 500,
since = since?.get(relay)?.time,
),
),
// Podcasting-2.0 show metadata rides on the generic NIP-78 app-data kind (30078),
// so it needs its own #d=podcast-metadata filter rather than a bare kind match.
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AppSpecificDataEvent.KIND),
authors = listOf(user.pubkeyHex),
tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)),
limit = 1,
since = since?.get(relay)?.time,
),
),
)
}
}
@@ -0,0 +1,38 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class MyPodcastFeedSubAssembler(
client: INostrClient,
allKeys: () -> Set<MyPodcastQueryState>,
) : PerUserEoseManager<MyPodcastQueryState>(client, allKeys) {
override fun updateFilter(
key: MyPodcastQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterMyPodcast(user(key), since)
override fun user(key: MyPodcastQueryState) = key.user
}
@@ -0,0 +1,46 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// Keyed by the logged-in creator's own pubkey. Drives the authoring hub's REQ for the creator's
// own Podcasting-2.0 catalog (episodes, trailers, and the show-metadata event).
class MyPodcastQueryState(
val user: User,
)
class MyPodcastFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<MyPodcastQueryState>() {
val group =
listOf(
MyPodcastFeedSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,41 @@
/*
* 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.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* While the authoring hub is on screen, keeps a REQ open for the logged-in creator's own
* Podcasting-2.0 catalog (episodes, trailers, and show metadata), so the hub fills in even when the
* events weren't already cached.
*/
@Composable
fun MyPodcastFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
val state =
remember(accountViewModel) {
MyPodcastQueryState(accountViewModel.account.userProfile())
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().myPodcast)
}
@@ -30,8 +30,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_METADATA_D_FILTER
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity
@@ -52,22 +54,28 @@ fun makePodcastsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince)
): List<RelayBasedFilter> =
// Two REQs: NIP-F4 shows (kind:10154, no tag constraint) plus Podcasting-2.0 shows
// (kind:30078, constrained to `#d=["podcast-metadata"]` so the overloaded app-data kind
// doesn't flood the feed). Both land in the merged PodcastsFeedFilter.
makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) +
makePodcastFilter(feedSettings, PODCASTING20_METADATA_KINDS, since, defaultSince, PODCAST_METADATA_D_FILTER)
private fun makePodcastFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince)
is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince, additionalTags)
is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince, additionalTags)
is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince, additionalTags)
is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince, additionalTags)
is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince, additionalTags)
is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince, additionalTags)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince, additionalTags)
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince, additionalTags)
else -> emptyList()
}
@@ -27,14 +27,42 @@ 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
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
// Each podcast screen issues its own REQ keyed on its own follow-list selector — keep
// the kind lists split so episodes and show metadata never co-mingle in one filter.
internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND)
// Episodes span both podcast drafts: NIP-F4 kind 54 (podcast-is-a-keypair) and Podcasting-2.0
// kind 30054 (creator-is-a-keypair). Both are authored by the followed pubkey, so the same
// author-scoped REQ pulls them into one merged feed.
internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND)
internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND)
// Podcasting-2.0 stores show metadata as a kind:30078 NIP-78 app-data event. That kind is
// heavily overloaded, so this REQ MUST be constrained by `#d=["podcast-metadata"]` (see
// [PODCAST_METADATA_D_FILTER]) or it would pull every client's app-data. Kept separate from
// [PODCAST_KINDS] because the NIP-F4 kind:10154 metadata must NOT carry the `#d` constraint.
internal val PODCASTING20_METADATA_KINDS = listOf(AppSpecificDataEvent.KIND)
internal val PODCAST_METADATA_D_FILTER = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG))
/**
* Merges two relay-filter tag maps, unioning the value lists per key. Used to layer an extra
* constraint (e.g. `#d`) onto a variant that already pins its own tags (`#t`, `#g`, `#a`).
* A null on either side passes the other through unchanged.
*/
internal fun mergeFilterTags(
base: Map<String, List<String>>?,
extra: Map<String, List<String>>?,
): Map<String, List<String>>? =
when {
base == null -> extra
extra == null -> base
else -> (base.keys + extra.keys).associateWith { (base[it].orEmpty() + extra[it].orEmpty()).distinct() }
}
// NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is
// its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list —
// they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent).
@@ -47,6 +75,7 @@ fun filterPodcastEventsByAuthors(
kinds: List<Int>,
authors: Set<HexKey>,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
@@ -56,6 +85,7 @@ fun filterPodcastEventsByAuthors(
Filter(
authors = authorList,
kinds = kinds,
tags = additionalTags,
limit = 200,
since = since,
),
@@ -68,6 +98,7 @@ fun filterPodcastEventsByAuthors(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
@@ -81,6 +112,7 @@ fun filterPodcastEventsByAuthors(
kinds = kinds,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -91,6 +123,7 @@ fun filterPodcastEventsByMutedAuthors(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
@@ -104,6 +137,7 @@ fun filterPodcastEventsByMutedAuthors(
kinds = kinds,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -33,6 +33,7 @@ fun filterPodcastEventsFromAllCommunities(
kinds: List<Int>,
communities: Set<String>,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
val communityList = communities.sorted()
val kindsAsStrings = kinds.map { it.toString() }
@@ -57,7 +58,7 @@ fun filterPodcastEventsFromAllCommunities(
filter =
Filter(
kinds = kinds,
tags = mapOf("a" to communityList),
tags = mergeFilterTags(mapOf("a" to communityList), additionalTags),
limit = communityList.size * 20,
since = since,
),
@@ -70,6 +71,7 @@ fun filterPodcastEventsByAllCommunities(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
@@ -79,6 +81,7 @@ fun filterPodcastEventsByAllCommunities(
kinds = kinds,
communities = it.value.communities,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}
@@ -89,6 +92,7 @@ fun filterPodcastEventsFromCommunity(
community: String,
authors: Set<String>?,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
val authorList = authors?.sorted()
val kindsAsStrings = kinds.map { it.toString() }
@@ -114,7 +118,7 @@ fun filterPodcastEventsFromCommunity(
Filter(
authors = authorList,
kinds = kinds,
tags = mapOf("a" to listOf(community)),
tags = mergeFilterTags(mapOf("a" to listOf(community)), additionalTags),
limit = 100,
since = since,
),
@@ -127,6 +131,7 @@ fun filterPodcastEventsByCommunity(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
@@ -137,6 +142,7 @@ fun filterPodcastEventsByCommunity(
community = it.value.community,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}
@@ -29,6 +29,7 @@ fun filterPodcastEventsByFollows(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
@@ -38,7 +39,7 @@ fun filterPodcastEventsByFollows(
listOfNotNull(
it.value.authors?.let { authors ->
filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay)
filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay, additionalTags)
},
).flatten()
}
@@ -31,6 +31,7 @@ fun filterPodcastEventsByGeohashes(
kinds: List<Int>,
geotags: Set<String>,
since: Long?,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
@@ -40,7 +41,7 @@ fun filterPodcastEventsByGeohashes(
filter =
Filter(
kinds = kinds,
tags = mapOf("g" to geotags.sorted()),
tags = mergeFilterTags(mapOf("g" to geotags.sorted()), additionalTags),
limit = 100,
since = since,
),
@@ -53,6 +54,7 @@ fun filterPodcastEventsByGeohashes(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
@@ -66,6 +68,7 @@ fun filterPodcastEventsByGeohashes(
kinds = kinds,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -31,6 +31,7 @@ fun filterPodcastEventsByHashtag(
kinds: List<Int>,
hashtags: Set<String>,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
@@ -38,7 +39,7 @@ fun filterPodcastEventsByHashtag(
filter =
Filter(
kinds = kinds,
tags = mapOf("t" to hashtags.sorted()),
tags = mergeFilterTags(mapOf("t" to hashtags.sorted()), additionalTags),
limit = 200,
since = since,
),
@@ -50,6 +51,7 @@ fun filterPodcastEventsByHashtag(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
@@ -63,6 +65,7 @@ fun filterPodcastEventsByHashtag(
kinds = kinds,
hashtags = it.value.hashtags,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -30,6 +30,7 @@ fun filterPodcastEventsGlobal(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
@@ -42,6 +43,7 @@ fun filterPodcastEventsGlobal(
filter =
Filter(
kinds = kinds,
tags = additionalTags,
limit = 200,
since = sinceForRelay,
),
@@ -220,6 +220,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
@@ -324,6 +325,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
@@ -341,6 +343,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@@ -832,8 +837,15 @@ private fun FullBleedNoteCompose(
RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is PodcastEpisodeEvent) {
RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is Podcasting20EpisodeEvent) {
RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is Podcasting20TrailerEvent) {
PodcastTrailerListItem(baseNote, accountViewModel, nav)
} else if (noteEvent is PodcastMetadataEvent) {
RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AppSpecificDataEvent && noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) {
// kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a podcast card.
RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is CommunityPostApprovalEvent) {
RenderPostApproval(
baseNote,
+114
View File
@@ -995,6 +995,118 @@
<string name="route_podcasts">Podcasts</string>
<string name="podcast_view_episodes">View episodes</string>
<string name="podcast_no_episodes">No episodes found yet</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_trailer_season">Season %1$d</string>
<string name="podcast_explicit">Explicit</string>
<string name="podcast_completed">Completed</string>
<string name="podcast_premium">Premium</string>
<string name="podcast_support_show">Support the show</string>
<string name="podcast_by_author">by %1$s</string>
<string name="podcast_season_episode">S%1$d · E%2$d</string>
<string name="podcast_episode_number">Ep %1$d</string>
<string name="podcast_season">Season %1$d</string>
<string name="podcast_video">Video</string>
<string name="podcast_transcript">Transcript</string>
<string name="podcast_chapters">Chapters</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_zap_split_hint">Zaps to this are split between:</string>
<string name="podcast_hosts_and_guests">Hosts &amp; Guests</string>
<string name="podcast_play_soundbite">Play highlight</string>
<string name="podcast_top_supporters">Top Supporters</string>
<plurals name="podcast_chapters_count">
<item quantity="one">%1$d chapter</item>
<item quantity="other">%1$d chapters</item>
</plurals>
<string name="podcast_role_host">Host</string>
<string name="podcast_role_cohost">Co-host</string>
<string name="podcast_role_editor">Editor</string>
<string name="podcast_author_verified">Verified author</string>
<string name="podcast_value_error_title">Value-for-Value error</string>
<string name="podcast_value_no_recipients">This podcast has no payable value recipients.</string>
<string name="podcast_value_keysend_requires_nwc">Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients.</string>
<string name="podcast_value_stream">Stream sats</string>
<string name="podcast_value_stream_rate">%1$d sats/min</string>
<string name="podcast_value_stream_hint">Sends value automatically while you listen.</string>
<string name="podcast_value_streamed_total">Streamed %1$d sats this session</string>
<string name="podcast_value_stream_requires_wallet">Connect a Nostr Wallet Connect or debit wallet to stream sats while listening.</string>
<string name="podcast_new_episode">New episode</string>
<string name="podcast_edit_episode">Edit episode</string>
<string name="podcast_publishing_banner">Publishing…</string>
<string name="podcast_cover_upload_cta">Add cover art</string>
<string name="podcast_cover_upload_hint">Square image shown for the episode</string>
<string name="podcast_episode_title_label">Title</string>
<string name="podcast_episode_title_placeholder">Episode title</string>
<string name="podcast_episode_summary_label">Summary</string>
<string name="podcast_episode_duration_label">Duration (seconds)</string>
<string name="podcast_episode_more_details">More details</string>
<string name="podcast_episode_season_label">Season</string>
<string name="podcast_episode_number_label">Episode #</string>
<string name="podcast_episode_video_label">Video URL</string>
<string name="podcast_episode_transcript_label">Transcript URL</string>
<string name="podcast_episode_chapters_label">Chapters URL</string>
<string name="podcast_episode_topics_label">Topics</string>
<string name="podcast_episode_topics_placeholder">comma, separated, tags</string>
<string name="podcast_episode_audio_url_label">Audio URL</string>
<string name="podcast_episode_audio_url_placeholder">https://…/episode.mp3</string>
<string name="podcast_episode_audio_picked">Audio file ready to upload</string>
<string name="podcast_episode_audio_upload_cta">Add audio file</string>
<string name="podcast_episode_audio_upload_hint">MP3, M4A, or other audio</string>
<string name="podcast_episode_delete">Delete episode</string>
<string name="podcast_episode_delete_confirm">Delete this episode? This can\'t be undone.</string>
<string name="podcast_edit_show">Your podcast</string>
<string name="podcast_show_cover_cta">Add cover art</string>
<string name="podcast_show_cover_hint">Square artwork for your show</string>
<string name="podcast_show_title_label">Show title</string>
<string name="podcast_show_title_placeholder">My Podcast</string>
<string name="podcast_show_description_label">Description</string>
<string name="podcast_show_author_label">Author</string>
<string name="podcast_show_email_label">Contact email</string>
<string name="podcast_show_website_label">Website</string>
<string name="podcast_show_categories_label">Categories</string>
<string name="podcast_show_categories_placeholder">Technology, News</string>
<string name="podcast_show_funding_label">Funding links</string>
<string name="podcast_show_funding_placeholder">https://…</string>
<string name="podcast_show_language_label">Language</string>
<string name="podcast_show_language_placeholder">en</string>
<string name="podcast_show_copyright_label">Copyright</string>
<string name="podcast_show_type_label">Show type</string>
<string name="podcast_show_type_episodic">Episodic</string>
<string name="podcast_show_type_serial">Serial</string>
<string name="podcast_show_explicit">Explicit content</string>
<string name="podcast_show_complete">Show complete (no more episodes)</string>
<string name="podcast_show_locked">Locked (premium)</string>
<string name="podcast_new_trailer">New trailer</string>
<string name="podcast_trailer_upload_cta">Add trailer clip</string>
<string name="podcast_trailer_upload_hint">Short audio or video preview</string>
<string name="podcast_trailer_title_placeholder">Trailer title</string>
<string name="podcast_trailer_url_label">Media URL</string>
<string name="podcast_your_podcast">Your podcast</string>
<string name="podcast_untitled">Untitled</string>
<string name="podcast_no_episodes_yet">No episodes yet. Tap New episode to publish your first one.</string>
<string name="podcast_create_your_show">Create your podcast</string>
<string name="podcast_tap_to_edit_show">Tap to edit show details</string>
<string name="podcast_create_show_hint">Set up your show\'s title, art, and details</string>
<plurals name="podcast_trailer_count">
<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_add_address">Add address manually</string>
<string name="podcast_value_search_user">Add a Nostr user</string>
<string name="podcast_value_search_user_hint">Search by name or @handle</string>
<string name="podcast_value_user_no_lnaddress">This user has no Lightning address</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>
@@ -1025,6 +1137,8 @@
<string name="public_bookmarks">Public Bookmarks</string>
<string name="repository_bookmarks">Repositories</string>
<string name="repository_bookmarks_explainer">Your bookmarked git repositories</string>
<string name="podcast_bookmarks">Podcasts</string>
<string name="podcast_bookmarks_explainer">Your bookmarked podcasts and episodes</string>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
<string name="add_to_public_bookmarks">Add to Public Bookmarks</string>
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
@@ -0,0 +1,65 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class MergeFilterTagsTest {
@Test
fun `null base passes the extra through`() {
assertEquals(mapOf("d" to listOf("podcast-metadata")), mergeFilterTags(null, PODCAST_METADATA_D_FILTER))
}
@Test
fun `null extra passes the base through`() {
val base = mapOf("t" to listOf("tech"))
assertEquals(base, mergeFilterTags(base, null))
}
@Test
fun `both null stays null`() {
assertNull(mergeFilterTags(null, null))
}
@Test
fun `disjoint keys are unioned - layering d onto an existing t constraint`() {
val merged = mergeFilterTags(mapOf("t" to listOf("tech")), PODCAST_METADATA_D_FILTER)
assertEquals(
mapOf("t" to listOf("tech"), "d" to listOf("podcast-metadata")),
merged,
)
}
@Test
fun `same key merges and de-duplicates values`() {
val merged = mergeFilterTags(mapOf("d" to listOf("podcast-metadata")), mapOf("d" to listOf("podcast-metadata", "other")))
assertEquals(mapOf("d" to listOf("podcast-metadata", "other")), merged)
}
@Test
fun `the d filter targets the podstr metadata kind and d-tag`() {
assertEquals(listOf(AppSpecificDataEvent.KIND), PODCASTING20_METADATA_KINDS)
assertEquals(mapOf("d" to listOf("podcast-metadata")), PODCAST_METADATA_D_FILTER)
}
}
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.cli.commands.NotesCommands
import com.vitorpamplona.amethyst.cli.commands.NsiteCommands
import com.vitorpamplona.amethyst.cli.commands.OfferCommands
import com.vitorpamplona.amethyst.cli.commands.OutboxCommand
import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands
import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
@@ -233,6 +234,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"serve" -> ServeCommand.run(dataDir, tail)
"cashu" -> CashuCommands.dispatch(dataDir, tail)
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
"bunker" -> BunkerCommand.run(dataDir, tail)
else -> {
System.err.println("unknown subcommand: $head")
@@ -478,6 +480,21 @@ private fun printUsage() {
| [--image URL] [--content MARKDOWN]
| podcast list [USER] [--limit N] list a user's metadata + episodes
|
|Podcasts (Podcasting 2.0 / podstr):
| podcast20 metadata --title T publish kind:30078 show metadata (JSON body)
| [--description D] [--author A] [--image URL] [--language L]
| [--categories A,B] [--funding URL,URL] [--website URL]
| [--copyright C] [--type episodic|serial] [--explicit] [--complete]
| [--value-json JSON] value-for-value split block
| podcast20 episode --title T --audio URL[,URL] publish a kind:30054 episode
| [--d ID] [--audio-type MIME] [--description D] [--image URL]
| [--duration SECS] [--video URL] [--video-type MIME]
| [--episode N] [--season N] [--transcript URL] [--chapters URL]
| [--value-json JSON] [--topic A,B] [--content MARKDOWN] [--pubdate RFC2822]
| podcast20 trailer --title T --url URL publish a kind:30055 trailer
| [--d ID] [--type MIME] [--length BYTES] [--season N] [--pubdate RFC2822]
| podcast20 list [USER] [--limit N] list a creator's metadata + episodes + trailers
|
|Static websites (NIP-5A kind:15128/35128):
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
| [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
@@ -0,0 +1,327 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastValue
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
/**
* `amy podcast20 <metadata|episode|trailer|list>` the Podcasting-2.0 draft (derekross/podstr),
* kept separate from the NIP-F4 `podcast` commands because the two models differ: here the
* logged-in account IS the creator and signs everything with its own key, and episodes/trailers
* are addressable (`d`-tag) events that can be edited in place.
*
* metadata publish kind:30078 show metadata (`d=podcast-metadata`, JSON body)
* episode publish a kind:30054 episode
* trailer publish a kind:30055 trailer
* list list a creator's metadata + episodes + trailers
*
* Thin assembly only: events and JSON live in quartz (`Podcasting20EpisodeEvent`,
* `Podcasting20TrailerEvent`, `Podcasting20PodcastMetadata`).
*/
object Podcast20Commands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"podcast20",
tail,
"podcast20 <metadata|episode|trailer|list>",
mapOf(
"metadata" to { rest -> metadata(dataDir, rest) },
"episode" to { rest -> episode(dataDir, rest) },
"trailer" to { rest -> trailer(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
),
)
private suspend fun metadata(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 metadata requires --title")
val value =
valueFlag(args).getOrElse {
return Output.error("bad_args", "podcast20 metadata --value-json is not valid JSON")
}
val content =
Podcasting20PodcastMetadata.Content(
title = title,
description = args.flag("description"),
author = args.flag("author"),
email = args.flag("email"),
image = args.flag("image"),
language = args.flag("language"),
categories = listFlag(args, "categories"),
explicit = trueIfPresent(args, "explicit"),
website = args.flag("website"),
copyright = args.flag("copyright"),
funding = listFlag(args, "funding"),
locked = trueIfPresent(args, "locked"),
type = args.flag("type"),
complete = trueIfPresent(args, "complete"),
guid = args.flag("guid"),
value = value,
)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val signed = ctx.signer.sign(Podcasting20PodcastMetadata.build(content))
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"d" to Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG,
"title" to title,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
}
}
private suspend fun episode(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 episode requires --title")
val audioType = args.flag("audio-type")
val audios =
args
.flag("audio")
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.map { PodcastAudio(it, audioType) }
.orEmpty()
if (audios.isEmpty()) return Output.error("bad_args", "podcast20 episode requires --audio URL[,URL…]")
val value =
valueFlag(args).getOrElse {
return Output.error("bad_args", "podcast20 episode --value-json is not valid JSON")
}
val dTag = args.flag("d") ?: generateDTag("episode")
val video = args.flag("video")?.let { PodcastAudio(it, args.flag("video-type")) }
Context.open(dataDir).use { ctx ->
ctx.prepare()
val template =
Podcasting20EpisodeEvent.build(
dTag = dTag,
title = title,
audios = audios,
pubdate = args.flag("pubdate") ?: rfc2822Now(),
description = args.flag("description"),
image = args.flag("image"),
durationInSeconds = args.flag("duration")?.toLongOrNull(),
video = video,
episodeNumber = args.flag("episode")?.toIntOrNull(),
season = args.flag("season")?.toIntOrNull(),
transcriptUrl = args.flag("transcript"),
chaptersUrl = args.flag("chapters"),
value = value,
topics = listFlag(args, "topic"),
markdownContent = args.flag("content", "") ?: "",
)
val signed = ctx.signer.sign(template)
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"d" to dTag,
"title" to title,
"audios" to audios.map { it.url },
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
}
}
private suspend fun trailer(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 trailer requires --title")
val url = args.flag("url") ?: return Output.error("bad_args", "podcast20 trailer requires --url")
val dTag = args.flag("d") ?: generateDTag("trailer")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val template =
Podcasting20TrailerEvent.build(
dTag = dTag,
title = title,
url = url,
pubdate = args.flag("pubdate") ?: rfc2822Now(),
lengthInBytes = args.flag("length")?.toLongOrNull(),
mimeType = args.flag("type"),
season = args.flag("season")?.toIntOrNull(),
)
val signed = ctx.signer.sign(template)
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"d" to dTag,
"title" to title,
"url" to url,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val limit = args.intFlag("limit", 50)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
val relays = RawEventSupport.queryTargets(ctx, args)
val received =
ctx.drain(
relays.associateWith {
listOf(
Filter(
kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND, AppSpecificDataEvent.KIND),
authors = listOf(author),
limit = limit,
),
)
},
)
val events = received.map { it.second }.distinctBy { it.id }
val show =
events
.filterIsInstance<AppSpecificDataEvent>()
.mapNotNull { Podcasting20PodcastMetadata.parse(it) }
.maxByOrNull { it.event.createdAt }
val episodes =
events
.filterIsInstance<Podcasting20EpisodeEvent>()
.sortedByDescending { it.createdAt }
.map {
mapOf(
"event_id" to it.id,
"d" to it.dTag(),
"title" to it.title(),
"season" to it.season(),
"episode" to it.number(),
"audios" to it.audios().map { a -> a.url },
"created_at" to it.createdAt,
)
}
val trailers =
events
.filterIsInstance<Podcasting20TrailerEvent>()
.sortedByDescending { it.createdAt }
.map {
mapOf(
"event_id" to it.id,
"d" to it.dTag(),
"title" to it.title(),
"url" to it.url(),
"season" to it.season(),
"created_at" to it.createdAt,
)
}
Output.emit(
mapOf(
"pubkey" to author,
"metadata" to
show?.let {
mapOf(
"title" to it.showTitle(),
"description" to it.showDescription(),
"image" to it.showImage(),
"author" to it.showAuthor(),
"categories" to it.showCategories(),
"funding" to it.showFundingUrls(),
)
},
"episode_count" to episodes.size,
"episodes" to episodes,
"trailer_count" to trailers.size,
"trailers" to trailers,
),
)
return 0
}
}
private fun listFlag(
args: Args,
name: String,
): List<String> =
args
.flag(name)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/** A boolean flag maps to `true` when present and `null` when absent, so it's omitted from the JSON. */
private fun trueIfPresent(
args: Args,
name: String,
): Boolean? = if (args.bool(name)) true else null
/**
* Parses the `--value-json` value-for-value block. Success with null means the flag was absent;
* a failure means it was present but malformed (the caller turns that into a bad_args error).
*/
private fun valueFlag(args: Args): Result<PodcastValue?> {
val json = args.flag("value-json") ?: return Result.success(null)
return runCatching { JsonMapper.fromJson<PodcastValue>(json) }
}
private fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}"
/** Current time as an RFC2822 date string (e.g. `Tue, 24 Jun 2025 12:00:00 GMT`), as the spec's `pubdate` expects. */
private fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")))
}
@@ -0,0 +1,148 @@
/*
* 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.commons.podcasts
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.commons.model.User
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.
*
* Lives in `commons` (a CLI-safe snapshot-state holder, no Compose UI) so any front end can drive a
* V4V split editor; the actual editor composable is platform-side.
*/
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)) }
}
/** Add a blank row for a raw destination (a node keysend, or a non-Nostr lightning address). */
fun addManual() {
recipients.add(RecipientDraft())
}
/**
* Add a Nostr user as a recipient. Returns false (and adds nothing) if the user has no lightning
* address to pay there's nothing to put in the value block. Duplicate users are ignored.
*/
fun addUser(user: User): Boolean {
if (user.lnAddress().isNullOrBlank()) return false
if (recipients.any { it.user.value?.pubkeyHex == user.pubkeyHex }) return true
recipients.add(RecipientDraft.forUser(user))
return true
}
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. Either backed by a Nostr [user] (rendered with avatar + name; its
* lightning address resolves at save time) or a raw destination typed by hand ([name]/[isNode]/
* [address]). All fields are Compose state so the editor recomposes as they change.
*/
class RecipientDraft {
/** When set, this row is a Nostr user — paid at their lud16, shown with avatar + name. */
val user = mutableStateOf<User?>(null)
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 resolves to an address and has a positive weight. */
fun toRecipient(): PodcastValueRecipient? {
val weight = split.value.trim().toIntOrNull() ?: return null
if (weight <= 0) return null
user.value?.let { u ->
val lud = u.lnAddress()?.takeIf { it.isNotBlank() } ?: return null
return PodcastValueRecipient(
name = u.toBestDisplayName(),
type = PodcastValue.TYPE_LNADDRESS,
address = lud,
split = weight,
fee = if (fee.value) true else null,
)
}
val addr = address.value.trim()
if (addr.isBlank()) 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 forUser(user: User): RecipientDraft =
RecipientDraft().apply {
this.user.value = user
}
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
}
}
}
@@ -300,6 +300,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
/**
* Human-readable label and defining NIP for a Nostr event kind.
@@ -337,6 +339,8 @@ object KindNames {
PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"),
AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"),
FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"),
Podcasting20EpisodeEvent.KIND to KindName("Podcast Episode (Podcasting 2.0)", null),
Podcasting20TrailerEvent.KIND to KindName("Podcast Trailer (Podcasting 2.0)", null),
AttestationEvent.KIND to KindName("Attestation", null),
AttestationRequestEvent.KIND to KindName("Attestation Request", null),
AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null),
@@ -25,11 +25,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -48,6 +51,8 @@ class PodcastEpisodeEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
PodcastEpisode,
RootScope,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
@@ -59,6 +64,20 @@ class PodcastEpisodeEvent(
fun audios() = tags.mapNotNull(AudioTag::parse)
override fun episodeTitle() = title()
override fun episodeImage() = image()
override fun episodeDescription() = description()
override fun episodeAudio() = audios().map { PodcastAudio(it.url, it.mediaType) }
// NIP-F4 episodes carry no duration tag; clients derive it from the audio stream.
override fun episodeDurationInSeconds(): Long? = null
// NIP-F4 episodes are regular events ordered by their publication time.
override fun episodePublishedAt() = createdAt
companion object {
const val KIND = 54
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag
import com.vitorpamplona.quartz.podcasts.PodcastShow
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -54,6 +55,7 @@ class PodcastMetadataEvent(
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PodcastShow,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n")
@@ -65,6 +67,14 @@ class PodcastMetadataEvent(
fun websites() = tags.mapNotNull(WebsiteTag::parse)
override fun showTitle() = title()
override fun showImage() = image()
override fun showDescription() = description()
override fun showWebsites() = websites()
/**
* Returns claimed authors and their roles. The spec warns these claims are
* unverified a podcast can name anyone as author. Before surfacing an author
@@ -73,9 +83,22 @@ class PodcastMetadataEvent(
*/
fun claimedAuthors() = tags.mapNotNull(AuthorTag::parse)
/**
* Fingerprint of a known spam flood thousands of identical headless-test "Mock Podcast"
* shows. Their structure is exactly `title="Mock Podcast"`, `description="Headless test feed"`,
* `content="Headless test feed"`. Matched so the client can drop them before consuming.
*/
fun isMockSpam(): Boolean =
content == MOCK_SPAM_CONTENT &&
title() == MOCK_SPAM_TITLE &&
description() == MOCK_SPAM_CONTENT
companion object {
const val KIND = 10154
private const val MOCK_SPAM_TITLE = "Mock Podcast"
private const val MOCK_SPAM_CONTENT = "Headless test feed"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null)
@@ -0,0 +1,190 @@
/*
* 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.quartz.nipXXPodcasting20.episode
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ChaptersTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EpisodeNumberTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ValueTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
import com.vitorpamplona.quartz.podcasts.PodcastPerson
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Podcasting-2.0 draft podcast episode (`kind:30054`), as published by clients like
* derekross/podstr. Unlike NIP-F4 (where the podcast is its own keypair and episodes
* are regular `kind:54` events), here the **human creator** is the keypair and each
* episode is an *addressable* event keyed by its `d` tag, so it can be edited in place.
*
* Implements the spec-neutral [PodcastEpisode] so it lands in the same merged
* episode list as NIP-F4 episodes.
*/
@Immutable
class Podcasting20EpisodeEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PodcastEpisode,
RootScope,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun audios() = tags.mapNotNull(AudioTag::parse)
fun video() = tags.firstNotNullOfOrNull(VideoTag::parse)
fun number() = tags.firstNotNullOfOrNull(EpisodeNumberTag::parse)
fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse)
fun transcriptUrl() = tags.firstNotNullOfOrNull(TranscriptTag::parse)
fun chaptersUrl() = tags.firstNotNullOfOrNull(ChaptersTag::parse)
fun value() = tags.firstNotNullOfOrNull(ValueTag::parse)
fun persons() = tags.mapNotNull(PersonTag::parse)
fun soundbites() = tags.mapNotNull(SoundbiteTag::parse)
fun durationInSeconds() = tags.firstNotNullOfOrNull(DurationTag::parse)
/** RFC2822 publication date string, kept verbatim for RSS generation. */
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
fun topics() = hashtags()
/** Event id of the original publication when this is an edit, if present. */
fun editsEventId() = tags.firstNotNullOfOrNull(EditTag::parse)
override fun episodeTitle() = title()
override fun episodeImage() = image()
override fun episodeDescription() = description()
override fun episodeAudio() = audios()
override fun episodeDurationInSeconds() = durationInSeconds()
override fun episodePublishedAt() = createdAt
override fun episodeVideo() = video()
override fun episodeNumber() = number()
override fun episodeSeason() = season()
override fun episodeTranscriptUrl() = transcriptUrl()
override fun episodeChaptersUrl() = chaptersUrl()
override fun episodeValue() = value()
override fun episodePersons() = persons()
override fun episodeSoundbites() = soundbites()
companion object {
const val KIND = 30054
fun build(
dTag: String,
title: String,
audios: List<PodcastAudio>,
pubdate: String,
alt: String = "Podcast episode: $title",
description: String? = null,
image: String? = null,
durationInSeconds: Long? = null,
video: PodcastAudio? = null,
episodeNumber: Int? = null,
season: Int? = null,
transcriptUrl: String? = null,
chaptersUrl: String? = null,
value: PodcastValue? = null,
persons: List<PodcastPerson> = emptyList(),
soundbites: List<PodcastSoundbite> = emptyList(),
topics: List<String> = emptyList(),
markdownContent: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<Podcasting20EpisodeEvent>.() -> Unit = {},
) = eventTemplate<Podcasting20EpisodeEvent>(KIND, markdownContent, createdAt) {
dTag(dTag)
title(title)
audios.forEach { audio(it) }
pubdate(pubdate)
alt(alt)
description?.let { description(it) }
image?.let { image(it) }
durationInSeconds?.let { duration(it) }
video?.let { video(it) }
episodeNumber?.let { episodeNumber(it) }
season?.let { season(it) }
transcriptUrl?.let { transcript(it) }
chaptersUrl?.let { chapters(it) }
value?.let { value(it) }
persons.filter { it.isValid() }.forEach { person(it) }
soundbites.forEach { soundbite(it) }
if (topics.isNotEmpty()) hashtags(topics)
initializer()
}
}
}
@@ -0,0 +1,73 @@
/*
* 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.quartz.nipXXPodcasting20.episode
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ChaptersTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EpisodeNumberTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ValueTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastPerson
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
import com.vitorpamplona.quartz.podcasts.PodcastValue
fun TagArrayBuilder<Podcasting20EpisodeEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.video(video: PodcastAudio) = addUnique(VideoTag.assemble(video))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.episodeNumber(number: Int) = addUnique(EpisodeNumberTag.assemble(number))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.season(season: Int) = addUnique(SeasonTag.assemble(season))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.transcript(url: String) = addUnique(TranscriptTag.assemble(url))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.chapters(url: String) = addUnique(ChaptersTag.assemble(url))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.value(value: PodcastValue) = addUnique(ValueTag.assemble(value))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.person(person: PodcastPerson) = add(PersonTag.assemble(person))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.soundbite(soundbite: PodcastSoundbite) = add(SoundbiteTag.assemble(soundbite))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
@@ -0,0 +1,58 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 episode audio tag: `["audio", "<url>", "<optional_media_type>"]`.
*
* Wire-identical to the NIP-F4 audio tag, so it parses straight into the shared
* [PodcastAudio] holder that the unified [com.vitorpamplona.quartz.podcasts.PodcastEpisode]
* abstraction exposes.
*/
class AudioTag {
companion object {
const val TAG_NAME = "audio"
fun parse(tag: Array<String>): PodcastAudio? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
return PodcastAudio(tag[1], mediaType)
}
fun assemble(
url: String,
mediaType: String? = null,
): Array<String> =
if (mediaType.isNullOrEmpty()) {
arrayOf(TAG_NAME, url)
} else {
arrayOf(TAG_NAME, url, mediaType)
}
fun assemble(audio: PodcastAudio) = assemble(audio.url, audio.mediaType)
}
}
@@ -0,0 +1,43 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 episode chapters file URL: `["chapters", "<url>"]`. Points at a Podcasting-2.0
* JSON chapters document (timestamped chapter list) hosted off-event.
*/
class ChaptersTag {
companion object {
const val TAG_NAME = "chapters"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,39 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class DescriptionTag {
companion object {
const val TAG_NAME = "description"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(description: String) = arrayOf(TAG_NAME, description)
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 episode duration in whole seconds: `["duration", "3600"]`. */
class DurationTag {
companion object {
const val TAG_NAME = "duration"
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(durationInSeconds: Long) = arrayOf(TAG_NAME, durationInSeconds.toString())
}
}
@@ -0,0 +1,45 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 edit-history pointer: `["edit", "<original-event-id>"]`. References
* the event id of the original publication when an addressable episode/trailer is
* updated, so clients can reconstruct edit history.
*/
class EditTag {
companion object {
const val TAG_NAME = "edit"
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(originalEventId: HexKey) = arrayOf(TAG_NAME, originalEventId)
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 episode number within its season/show: `["episode", "5"]`. */
class EpisodeNumberTag {
companion object {
const val TAG_NAME = "episode"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(episodeNumber: Int) = arrayOf(TAG_NAME, episodeNumber.toString())
}
}
@@ -0,0 +1,39 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ImageTag {
companion object {
const val TAG_NAME = "image"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,59 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.podcasts.PodcastPerson
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 episode person credit: `["person", "<name>", "<role>", "<img>", "<href>"]`.
*
* Only the name (slot 1) is required; role/img/href are optional and carried positionally, with
* empty strings standing in for absent middle values. Maps to the shared [PodcastPerson] holder.
* (The show-level `podcast:person` `group` attribute isn't carried on the episode tag it's
* organizational metadata that lives in the show's JSON when present.)
*/
class PersonTag {
companion object {
const val TAG_NAME = "person"
fun parse(tag: Array<String>): PodcastPerson? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return PodcastPerson(
name = tag[1],
role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() },
img = tag.getOrNull(3)?.takeIf { it.isNotEmpty() },
href = tag.getOrNull(4)?.takeIf { it.isNotEmpty() },
)
}
fun assemble(person: PodcastPerson): Array<String> {
// Trim trailing empties so a person with only a name is a 2-element tag, but keep empty
// placeholders in the middle so href stays in slot 4 when role/img are missing.
val slots = listOf(person.name, person.role ?: "", person.img ?: "", person.href ?: "")
val lastNonEmpty = slots.indexOfLast { it.isNotEmpty() }.coerceAtLeast(0)
return (listOf(TAG_NAME) + slots.subList(0, lastNonEmpty + 1)).toTypedArray()
}
}
}
@@ -0,0 +1,47 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 publication date in RFC2822 format, set once when first published
* and preserved across edits: `["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"]`.
*
* The string is kept verbatim it feeds RSS generation, where the exact RFC2822
* spelling matters. Feed ordering relies on the event's `created_at` instead, so
* no date parsing is required here.
*/
class PubDateTag {
companion object {
const val TAG_NAME = "pubdate"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(rfc2822Date: String) = arrayOf(TAG_NAME, rfc2822Date)
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 season number an episode belongs to: `["season", "2"]`. */
class SeasonTag {
companion object {
const val TAG_NAME = "season"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString())
}
}
@@ -0,0 +1,53 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 episode soundbite: `["soundbite", "<startTime>", "<duration>", "<optional title>"]`.
*
* `startTime` and `duration` are in seconds (may be fractional). Title is optional. Maps to the
* shared [PodcastSoundbite] holder. A soundbite with a non-positive duration is dropped as invalid.
*/
class SoundbiteTag {
companion object {
const val TAG_NAME = "soundbite"
fun parse(tag: Array<String>): PodcastSoundbite? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val start = tag[1].toDoubleOrNull() ?: return null
val duration = tag[2].toDoubleOrNull() ?: return null
ensure(start >= 0.0) { return null }
ensure(duration > 0.0) { return null }
return PodcastSoundbite(start, duration, tag.getOrNull(3)?.takeIf { it.isNotEmpty() })
}
fun assemble(soundbite: PodcastSoundbite): Array<String> {
val head = arrayOf(TAG_NAME, soundbite.startTimeSeconds.toString(), soundbite.durationSeconds.toString())
val title = soundbite.title
return if (title.isNullOrEmpty()) head else head + title
}
}
}
@@ -0,0 +1,39 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class TitleTag {
companion object {
const val TAG_NAME = "title"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(title: String) = arrayOf(TAG_NAME, title)
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 episode transcript file URL: `["transcript", "<url>"]`. */
class TranscriptTag {
companion object {
const val TAG_NAME = "transcript"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,46 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 episode value-for-value tag: `["value", "<json>"]`, where the value is a JSON
* [PodcastValue] object (with `enabled` for episode-level overrides). Parses leniently a malformed
* body yields null rather than throwing.
*/
class ValueTag {
companion object {
const val TAG_NAME = "value"
fun parse(tag: Array<String>): PodcastValue? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return runCatching { JsonMapper.fromJson<PodcastValue>(tag[1]) }.getOrNull()
}
fun assemble(value: PodcastValue) = arrayOf(TAG_NAME, JsonMapper.toJson(value))
}
}
@@ -0,0 +1,56 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 episode video tag: `["video", "<url>", "<optional_media_type>"]`. The same wire
* shape as the audio tag; an episode MAY ship a video alongside (or instead of) its audio. Parses
* into the shared [PodcastAudio] media holder so a client can play it through the same path.
*/
class VideoTag {
companion object {
const val TAG_NAME = "video"
fun parse(tag: Array<String>): PodcastAudio? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
return PodcastAudio(tag[1], mediaType)
}
fun assemble(
url: String,
mediaType: String? = null,
): Array<String> =
if (mediaType.isNullOrEmpty()) {
arrayOf(TAG_NAME, url)
} else {
arrayOf(TAG_NAME, url, mediaType)
}
fun assemble(video: PodcastAudio) = assemble(video.url, video.mediaType)
}
}
@@ -0,0 +1,60 @@
/*
* 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.quartz.nipXXPodcasting20.metadata
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
import com.vitorpamplona.quartz.podcasts.PodcastShow
/**
* Cheap type/`d`-tag gate for whether [event] represents a podcast show used by feeds to decide
* inclusion without parsing the Podcasting-2.0 JSON content. Matches NIP-F4 `kind:10154` and the
* Podcasting-2.0 `kind:30078` app-data event with `d="podcast-metadata"`.
*/
fun isPodcastShowEvent(event: Event?): Boolean =
event is PodcastMetadataEvent ||
(event is AppSpecificDataEvent && event.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
/**
* Whether [event] is any podcast event a show ([isPodcastShowEvent]), an episode (NIP-F4 `kind:54`
* or Podcasting-2.0 `kind:30054`, both [PodcastEpisode]), or a Podcasting-2.0 trailer (`kind:30055`).
* Used to pull podcast items out of mixed lists (e.g. the NIP-51 bookmark list) for podcast-only views.
*/
fun isPodcastEvent(event: Event?): Boolean =
isPodcastShowEvent(event) ||
event is PodcastEpisode ||
event is Podcasting20TrailerEvent
/**
* Adapts [event] to the spec-neutral [PodcastShow], or returns null if it is not a podcast show
* (or its Podcasting-2.0 JSON content fails to parse). NIP-F4 metadata events implement
* [PodcastShow] directly; Podcasting-2.0 app-data events are wrapped via
* [Podcasting20PodcastMetadata.parse].
*/
fun resolvePodcastShow(event: Event?): PodcastShow? =
when (event) {
is PodcastMetadataEvent -> event
is AppSpecificDataEvent -> Podcasting20PodcastMetadata.parse(event)
else -> null
}
@@ -0,0 +1,135 @@
/*
* 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.quartz.nipXXPodcasting20.metadata
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.podcasts.PodcastPerson
import com.vitorpamplona.quartz.podcasts.PodcastShow
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.Serializable
/**
* The Podcasting-2.0 draft stores show-level metadata in a `kind:30078` (NIP-78 app-data) event
* with `d="podcast-metadata"`, where the channel fields live as a JSON object in `content` rather
* than in tags. This is a parsed, read-only view over such an event that adapts it to the
* spec-neutral [PodcastShow], so a podstr show merges into the same list and card as a NIP-F4
* [com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent].
*
* Note: `kind:30078` is heavily overloaded across NIPs and clients; only events whose `d` tag is
* exactly [PODCAST_METADATA_D_TAG] and whose content is valid podcast-metadata JSON resolve here.
*/
@Immutable
class Podcasting20PodcastMetadata(
val event: AppSpecificDataEvent,
private val content: Content,
) : PodcastShow {
override fun showTitle() = content.title
override fun showImage() = content.image
override fun showDescription() = content.description
override fun showWebsites() = listOfNotNull(content.website?.takeIf { it.isNotEmpty() })
override fun showAuthor() = content.author?.takeIf { it.isNotEmpty() }
override fun showCategories() = content.categories.filter { it.isNotEmpty() }
override fun showFundingUrls() = content.funding.filter { it.isNotEmpty() }
override fun showIsExplicit() = content.explicit ?: false
override fun showIsComplete() = content.complete ?: false
override fun showCopyright() = content.copyright?.takeIf { it.isNotEmpty() }
override fun showValue() = content.value
override fun showPersons() = content.persons.filter { it.isValid() }
fun language() = content.language
/** Contact email for the show, if provided. */
fun email() = content.email?.takeIf { it.isNotEmpty() }
/** "episodic" or "serial" per Podcasting 2.0, if provided. */
fun type() = content.type?.takeIf { it.isNotEmpty() }
/** Whether the show is locked (premium / subscription-gated). */
fun isLocked() = content.locked ?: false
/** The stable podcast GUID (Podcasting 2.0 `podcast:guid`), if provided. */
fun guid() = content.guid?.takeIf { it.isNotEmpty() }
/**
* The subset of the Podcasting-2.0 `kind:30078` metadata JSON this client reads. Unknown keys
* (notably `value` for value-for-value splits) are ignored by the lenient mapper and can be
* surfaced later without changing the wire format.
*/
@Serializable
class Content(
val title: String? = null,
val description: String? = null,
val author: String? = null,
val email: String? = null,
val image: String? = null,
val language: String? = null,
val categories: List<String> = emptyList(),
val explicit: Boolean? = null,
val website: String? = null,
val copyright: String? = null,
val funding: List<String> = emptyList(),
val locked: Boolean? = null,
val type: String? = null,
val complete: Boolean? = null,
val guid: String? = null,
val value: PodcastValue? = null,
val persons: List<PodcastPerson> = emptyList(),
)
companion object {
const val PODCAST_METADATA_D_TAG = "podcast-metadata"
/**
* Returns a view if [event] is a podcast-metadata app-data event with parseable JSON,
* otherwise null (wrong `d` tag, or non-JSON/encrypted content such as a user's own
* app settings).
*/
fun parse(event: AppSpecificDataEvent): Podcasting20PodcastMetadata? {
if (event.dTag() != PODCAST_METADATA_D_TAG) return null
val content = runCatching { JsonMapper.fromJson<Content>(event.content) }.getOrNull() ?: return null
return Podcasting20PodcastMetadata(event, content)
}
/**
* Builds the kind:30078 show-metadata event template (`d="podcast-metadata"`) by serializing
* [content] to its JSON body. Unset/default fields are omitted, keeping the payload minimal.
*/
fun build(
content: Content,
createdAt: Long = TimeUtils.now(),
): EventTemplate<AppSpecificDataEvent> = AppSpecificDataEvent.build(PODCAST_METADATA_D_TAG, JsonMapper.toJson(content), createdAt)
}
}
@@ -0,0 +1,98 @@
/*
* 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.quartz.nipXXPodcasting20.trailer
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Podcasting-2.0 draft podcast trailer (`kind:30055`), following the Podcast 2.0
* `<podcast:trailer>` element. Addressable like the episode (`kind:30054`) and
* signed by the human creator's keypair, keyed by its `d` tag.
*/
@Immutable
class Podcasting20TrailerEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun url() = tags.firstNotNullOfOrNull(UrlTag::parse)
/** RFC2822 publication date string, kept verbatim for RSS generation. */
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
fun lengthInBytes() = tags.firstNotNullOfOrNull(LengthTag::parse)
fun mimeType() = tags.firstNotNullOfOrNull(TypeTag::parse)
fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse)
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
companion object {
const val KIND = 30055
fun build(
dTag: String,
title: String,
url: String,
pubdate: String,
alt: String = "Podcast trailer: $title",
lengthInBytes: Long? = null,
mimeType: String? = null,
season: Int? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<Podcasting20TrailerEvent>.() -> Unit = {},
) = eventTemplate<Podcasting20TrailerEvent>(KIND, title, createdAt) {
dTag(dTag)
title(title)
url(url)
pubdate(pubdate)
alt(alt)
lengthInBytes?.let { length(it) }
mimeType?.let { type(it) }
season?.let { season(it) }
initializer()
}
}
}
@@ -0,0 +1,45 @@
/*
* 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.quartz.nipXXPodcasting20.trailer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
fun TagArrayBuilder<Podcasting20TrailerEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<Podcasting20TrailerEvent>.url(url: String) = addUnique(UrlTag.assemble(url))
fun TagArrayBuilder<Podcasting20TrailerEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
fun TagArrayBuilder<Podcasting20TrailerEvent>.length(lengthInBytes: Long) = addUnique(LengthTag.assemble(lengthInBytes))
fun TagArrayBuilder<Podcasting20TrailerEvent>.type(mimeType: String) = addUnique(TypeTag.assemble(mimeType))
fun TagArrayBuilder<Podcasting20TrailerEvent>.season(season: Int) = addUnique(SeasonTag.assemble(season))
fun TagArrayBuilder<Podcasting20TrailerEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 trailer file size in bytes: `["length", "1024000"]`. */
class LengthTag {
companion object {
const val TAG_NAME = "length"
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(lengthInBytes: Long) = arrayOf(TAG_NAME, lengthInBytes.toString())
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 season number a trailer represents: `["season", "2"]`. */
class SeasonTag {
companion object {
const val TAG_NAME = "season"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString())
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 trailer MIME type: `["type", "audio/mpeg"]`. */
class TypeTag {
companion object {
const val TAG_NAME = "type"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType)
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 trailer media URL: `["url", "<media-url>"]`. */
class UrlTag {
companion object {
const val TAG_NAME = "url"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,38 @@
/*
* 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.quartz.podcasts
import androidx.compose.runtime.Immutable
/**
* Spec-neutral media reference (a URL plus optional MIME type) for a podcast episode, used by the
* shared [PodcastEpisode] abstraction so a UI can play episodes regardless of which podcast NIP
* produced them. Despite the name it covers both audio and video sources.
*
* Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio in identical
* `["audio", "<url>", "<optional_media_type>"]` tags; the Podcasting-2.0 draft uses the same shape
* for its `video` tag. Each event maps its own tag class into this holder.
*/
@Immutable
class PodcastAudio(
val url: String,
val mediaType: String? = null,
)
@@ -0,0 +1,57 @@
/*
* 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.quartz.podcasts
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The Podcasting-2.0 keysend metadata blob ("boostagram") carried in TLV record
* [PodcastValue.PODCAST_TLV_RECORD] (7629169). It tells the receiving node which podcast/episode the
* payment is for and how much was sent in total. Field names follow the satoshis.stream convention
* (<https://github.com/satoshisstream/satoshis.stream/blob/main/TLV_registry.md>).
*
* Unset fields are omitted from the JSON ([JsonMapper] does not encode defaults), keeping the record
* small enough to fit comfortably inside a keysend onion.
*/
@Serializable
class PodcastBoostagram(
val podcast: String? = null,
val episode: String? = null,
/** "stream" for per-minute streaming sats, "boost" for a deliberate lump-sum tip. */
val action: String? = null,
@SerialName("app_name")
val appName: String? = null,
/** Total sats (not millisats) the listener sent across all splits. */
@SerialName("value_msat_total")
val valueMsatTotal: Long? = null,
val message: String? = null,
@SerialName("sender_name")
val senderName: String? = null,
) {
fun toJson(): String = JsonMapper.toJson(this)
companion object {
const val ACTION_STREAM = "stream"
const val ACTION_BOOST = "boost"
}
}
@@ -0,0 +1,59 @@
/*
* 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.quartz.podcasts
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable
/**
* The Podcasting-2.0 chapters document referenced by an episode's `chapters` tag a JSON file
* (per the podcast-namespace `chapters.json` spec) hosted off-event. Episodes carry only the URL;
* a client fetches and parses it into this model to render a tappable chapter list.
*/
@Immutable
@Serializable
class PodcastChapters(
val version: String? = null,
val chapters: List<PodcastChapter> = emptyList(),
) {
companion object {
/** Lenient parse of a chapters.json body; returns null on malformed input. */
fun parse(json: String): PodcastChapters? = runCatching { JsonMapper.fromJson<PodcastChapters>(json) }.getOrNull()
}
}
/** One chapter marker. [startTime] is in seconds (may be fractional per the spec). */
@Immutable
@Serializable
class PodcastChapter(
val startTime: Double = 0.0,
val title: String? = null,
/** Chapter artwork URL (the spec field is `img`). */
val img: String? = null,
/** A related link for the chapter. */
val url: String? = null,
/** Whether the chapter should appear in a table of contents; absent means yes. */
val toc: Boolean? = null,
) {
/** Whole-second start used for seeking/labeling. */
fun startSeconds(): Long = startTime.toLong()
}
@@ -0,0 +1,99 @@
/*
* 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.quartz.podcasts
/**
* Spec-neutral view of a single podcast episode.
*
* Two competing podcast drafts publish episodes with incompatible identity models
* and event kinds:
* - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent],
* `kind:54`): a regular event where the *podcast itself* is a Nostr keypair.
* - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent],
* `kind:30054`): an addressable event where the *human creator* is the keypair
* and episodes are editable via their `d` tag.
*
* The two cannot share a single wire kind, but a client can still render them in
* one list. This interface is that merge point: feeds and UI depend on it instead
* of a concrete event, so both kinds flow into the same podcast/episode list.
*/
interface PodcastEpisode {
/** The episode title shown in listings. */
fun episodeTitle(): String?
/** Cover/episode artwork URL, if any. */
fun episodeImage(): String?
/** Short, plain-text episode description/summary, if any. */
fun episodeDescription(): String?
/**
* One or more audio sources for the episode. Multiple entries typically offer
* the same audio in different containers/codecs (e.g. mp3 + opus).
*/
fun episodeAudio(): List<PodcastAudio>
/** Episode duration in seconds, if the publisher provided it. */
fun episodeDurationInSeconds(): Long?
/**
* Unix timestamp (seconds) used to order episodes in a merged feed. Both drafts
* fall back to the event's `created_at`; the Podcasting-2.0 draft additionally
* carries an RFC2822 `pubdate` tag exposed by its own event class.
*/
fun episodePublishedAt(): Long
/**
* A video source for the episode, if it ships one. NIP-F4 has no video tag and returns null;
* the Podcasting-2.0 draft carries a `video` tag.
*/
fun episodeVideo(): PodcastAudio? = null
/** Episode number within the show/season, if provided. */
fun episodeNumber(): Int? = null
/** Season number the episode belongs to, if provided. */
fun episodeSeason(): Int? = null
/** URL of an off-event transcript document, if provided. */
fun episodeTranscriptUrl(): String? = null
/** URL of an off-event Podcasting-2.0 chapters document, if provided. */
fun episodeChaptersUrl(): String? = null
/**
* The episode's value-for-value split block, if it overrides the show default. NIP-F4 has no
* V4V and returns null.
*/
fun episodeValue(): PodcastValue? = null
/**
* Hosts/guests credited on this specific episode (Podcasting-2.0 `podcast:person`). Empty when
* the publisher lists no per-episode people (NIP-F4 has no person tag and returns empty).
*/
fun episodePersons(): List<PodcastPerson> = emptyList()
/**
* Highlight clips of the episode (Podcasting-2.0 `podcast:soundbite`), each a start offset +
* duration into the audio. Empty when none are declared.
*/
fun episodeSoundbites(): List<PodcastSoundbite> = emptyList()
}
@@ -0,0 +1,67 @@
/*
* 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.quartz.podcasts
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.serialization.Serializable
/**
* A Podcasting-2.0 `podcast:person` a host, guest, or other contributor credited on a show or a
* single episode. Unlike a NIP-F4 author (a Nostr pubkey), a person is free-text: a [name] plus an
* optional [role]/[group], an avatar [img] URL, and a [href] link to their page. None of these need
* to be a Nostr identity, so it maps RSS `<podcast:person>` credits straight through.
*
* Carried two ways: show-level persons live in the `kind:30078` metadata JSON (a `persons` array),
* and episode-level persons are `["person", ...]` tags on the `kind:30054` event.
*/
@Immutable
@Serializable
class PodcastPerson(
val name: String = "",
/** e.g. "host", "guest", "cohost" — free text per the Podcasting 2.0 taxonomy. */
val role: String? = null,
/** Grouping such as "cast" or "writing"; rarely displayed, kept for round-trip fidelity. */
val group: String? = null,
/** Avatar image URL. */
val img: String? = null,
/** Link to the person's page/profile. */
val href: String? = null,
) {
fun isValid() = name.isNotBlank()
/**
* The Nostr pubkey (hex) this person points at, when [href] is (or embeds) an `npub`/`nprofile`
* including `nostr:` URIs and `njump.me`-style links. Null for a plain web link or no href.
* Lets a client upgrade a free-text credit to a real Nostr profile when the publisher linked one.
*/
fun nostrPubKey(): HexKey? =
href?.let {
when (val entity = Nip19Parser.uriToRoute(it)?.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
}
}
@@ -0,0 +1,80 @@
/*
* 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.quartz.podcasts
/**
* Spec-neutral view of a podcast show (the channel-level metadata), the companion of
* [PodcastEpisode].
*
* Two competing drafts model the show differently:
* - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent],
* `kind:10154`): a dedicated replaceable event whose own pubkey *is* the podcast, with the
* show fields in tags.
* - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata],
* a view over a `kind:30078` NIP-78 app-data event with `d="podcast-metadata"`): the creator's
* pubkey owns the show and the fields live in a JSON content blob.
*
* In both models the show's pubkey is also the author of its episodes, so a single show card and
* a single per-show episode list serve both. Feeds and UI depend on this interface to merge them.
*/
interface PodcastShow {
/** The show/podcast name. */
fun showTitle(): String?
/** Cover-art URL, if any. */
fun showImage(): String?
/** Show description/summary, if any. */
fun showDescription(): String?
/** Associated website URLs (possibly empty). */
fun showWebsites(): List<String>
/**
* Free-text author/host byline (not a Nostr pubkey), if the draft carries one. NIP-F4 models
* authors as pubkeys with roles instead, so it leaves this null.
*/
fun showAuthor(): String? = null
/** Genre/category labels (e.g. "Technology"), possibly empty. */
fun showCategories(): List<String> = emptyList()
/** Donation/funding page URLs (Podcasting 2.0 `funding`), possibly empty. */
fun showFundingUrls(): List<String> = emptyList()
/** Whether the show is flagged as explicit. */
fun showIsExplicit(): Boolean = false
/** Whether the show is marked complete/finished (no further episodes expected). */
fun showIsComplete(): Boolean = false
/** Copyright line, if provided. */
fun showCopyright(): String? = null
/** The show's default value-for-value split block, if any. NIP-F4 has no V4V and returns null. */
fun showValue(): PodcastValue? = null
/**
* Show-level hosts/guests (Podcasting-2.0 `podcast:person`) the recurring cast credited across
* the podcast. Empty when none are declared (NIP-F4 uses author p-tags instead and returns empty).
*/
fun showPersons(): List<PodcastPerson> = emptyList()
}

Some files were not shown because too many files have changed in this diff Show More