diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index fc6a9d14e3..59fc275990 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt new file mode 100644 index 0000000000..6fa95cfe3f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -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) -> 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, + 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, + message: String, + asZap: Boolean, + zapType: LnZapEvent.ZapType, + zappedNote: Note?, + okHttpClient: (String) -> OkHttpClient, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + ): List { + // 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, + zappedNote: Note?, + context: Context, + onError: (String, String) -> Unit, + onPayInvoicesViaIntent: (List) -> 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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt new file mode 100644 index 0000000000..5fafb991be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt @@ -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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 87084e060f..1d79f9f370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 79ce1181cc..bc929ff893 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PodcastEpisodesScreen(accountViewModel, nav) } composableFromEnd { PodcastsScreen(accountViewModel, nav) } composableFromEndArgs { PodcastScreen(it.pubkey, accountViewModel, nav) } + composableFromEnd { PodcastAuthoringScreen(accountViewModel, nav) } + composableFromEnd { EditPodcastShowScreen(accountViewModel, nav) } + composableFromEndArgs { NewPodcastEpisodeScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } + composableFromEnd { NewPodcastTrailerScreen(accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } @@ -446,6 +455,7 @@ fun BuildNavigation( composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } composableFromEnd { BookmarkedRepositoriesScreen(accountViewModel, nav) } + composableFromEnd { BookmarkedPodcastsScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 42760f3437..928af18b7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt index 99c2278462..ed5f975e6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a9c17d31f9..d7cd351841 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt new file mode 100644 index 0000000000..6e33d108df --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt @@ -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` + * 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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt new file mode 100644 index 0000000000..f5b8232eda --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt @@ -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, + ) : 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.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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt new file mode 100644 index 0000000000..a5706f8891 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt @@ -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(), 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)}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt new file mode 100644 index 0000000000..bd0840bd8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index faa0258569..fc9e6e3930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 51cde34882..b3b7093468 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -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, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index c7f829c4c3..6fa80e7a86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt new file mode 100644 index 0000000000..8d4552acfa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt @@ -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, + 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(), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt new file mode 100644 index 0000000000..00825d41a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt @@ -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, + 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" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt new file mode 100644 index 0000000000..5dce4e7177 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt @@ -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 + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt new file mode 100644 index 0000000000..86e24c54f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt @@ -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() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt new file mode 100644 index 0000000000..f698555e15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt @@ -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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 9e05c1d6a9..9a2b7b26af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -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}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 279cd83ceb..59f1f722fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) -> 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 37caa116b7..ea4e410601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index e1aa912ebd..f76f075764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt new file mode 100644 index 0000000000..f4b0531ef7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt new file mode 100644 index 0000000000..f9c83106da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt @@ -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() { + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List { + val bookmarks = account.bookmarkState.bookmarks.value + return (bookmarks.public + bookmarks.private) + .filter { isPodcastEvent(it.event) } + .distinct() + .sortedByDescending { it.createdAt() ?: 0L } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt new file mode 100644 index 0000000000..2c65ed9673 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt @@ -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 create(modelClass: Class): T = BookmarkPodcastsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt new file mode 100644 index 0000000000..33eb5e7aec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt @@ -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, + 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(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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index e75275cb42..a76a1a9796 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -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, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt index ca40ecb546..81c27cead6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 35f90c6444..7c9e736205 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index b161ad3aab..a7034479e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -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(metadataNote, accountViewModel) + val f4Event by observeNoteEvent(f4Note, accountViewModel) + val p20Event by observeNoteEvent(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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt new file mode 100644 index 0000000000..f642073f4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt new file mode 100644 index 0000000000..1f24265f4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt index 8762972787..ec3685bbfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt index 28dcbeb002..9022f588d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt new file mode 100644 index 0000000000..2917509791 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -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, + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt new file mode 100644 index 0000000000..8543e72b5e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt @@ -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(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val coverMedia = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(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) { + 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, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt new file mode 100644 index 0000000000..941a13c52c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -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, + 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)) } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt new file mode 100644 index 0000000000..1566ed11bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt @@ -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(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val coverMedia = mutableStateOf(null) + val audioMedia = mutableStateOf(null) + val pickedAudioName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(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) { + 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, + 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 = + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt new file mode 100644 index 0000000000..d7f7312460 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt @@ -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/*")) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt new file mode 100644 index 0000000000..1e6483d78b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt @@ -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(null) + + val isSending = mutableStateOf(false) + + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val media = mutableStateOf(null) + val pickedName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(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 } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt new file mode 100644 index 0000000000..ee6ee19595 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt new file mode 100644 index 0000000000..d034d20271 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt @@ -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): String? = + successful + .firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult } + ?.url + + private fun formatUploadErrors( + errors: List, + 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 = text.split(',').map { it.trim() }.filter { it.isNotEmpty() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt new file mode 100644 index 0000000000..7a9c5648ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -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)) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index 05522350b6..fd9a704db8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -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 { - 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): Set = 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt index 058e07bdac..6bb8070d66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -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 { 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): Set = 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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt index cc452a3f93..01d59060d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt @@ -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 { 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): Set = 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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt new file mode 100644 index 0000000000..8cd77217a9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt @@ -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 { + 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, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt index e526cf3174..6c061b11a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -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, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt new file mode 100644 index 0000000000..d9ce4aba05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt @@ -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, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: MyPodcastQueryState, + since: SincePerRelayMap?, + ): List = filterMyPodcast(user(key), since) + + override fun user(key: MyPodcastQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt new file mode 100644 index 0000000000..443d114108 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt @@ -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() { + val group = + listOf( + MyPodcastFeedSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..9fd9240b5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt index 19b65c62aa..d332342799 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -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 = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) +): List = + // 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, since: SincePerRelayMap?, defaultSince: Long?, + additionalTags: Map>? = null, ): List = 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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 4a56858280..48a5213fe0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -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>?, + extra: Map>?, +): Map>? = + 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, authors: Set, since: Long? = null, + additionalTags: Map>? = null, ): List { 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, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { 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, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index beb0d05142..edfd12d10f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -33,6 +33,7 @@ fun filterPodcastEventsFromAllCommunities( kinds: List, communities: Set, since: Long? = null, + additionalTags: Map>? = null, ): List { 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, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { 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?, since: Long? = null, + additionalTags: Map>? = null, ): List { 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, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { 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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt index 0b573739d2..4249f4982d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt @@ -29,6 +29,7 @@ fun filterPodcastEventsByFollows( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { 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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt index f746865b0d..7693832ccc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -31,6 +31,7 @@ fun filterPodcastEventsByGeohashes( kinds: List, geotags: Set, since: Long?, + additionalTags: Map>? = null, ): List { 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, since: SincePerRelayMap?, defaultSince: Long?, + additionalTags: Map>? = null, ): List { 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt index 124c464a17..cbd6961074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -31,6 +31,7 @@ fun filterPodcastEventsByHashtag( kinds: List, hashtags: Set, since: Long? = null, + additionalTags: Map>? = null, ): List = 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, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt index e0fc0c4cba..95e4778505 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -30,6 +30,7 @@ fun filterPodcastEventsGlobal( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (relays.set.isEmpty()) return emptyList() @@ -42,6 +43,7 @@ fun filterPodcastEventsGlobal( filter = Filter( kinds = kinds, + tags = additionalTags, limit = 200, since = sinceForRelay, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index e2319e7d03..85c84cfa69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 43a78b57a9..7ba32aceb8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -995,6 +995,118 @@ Podcasts View episodes No episodes found yet + Trailer + Season %1$d + Explicit + Completed + Premium + Support the show + by %1$s + S%1$d · E%2$d + Ep %1$d + Season %1$d + Video + Transcript + Chapters + Value-for-Value + %1$d%% + Zaps to this are split between: + Hosts & Guests + Play highlight + Top Supporters + + %1$d chapter + %1$d chapters + + Host + Co-host + Editor + Verified author + Value-for-Value error + This podcast has no payable value recipients. + Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. + Stream sats + %1$d sats/min + Sends value automatically while you listen. + Streamed %1$d sats this session + Connect a Nostr Wallet Connect or debit wallet to stream sats while listening. + New episode + Edit episode + Publishing… + Add cover art + Square image shown for the episode + Title + Episode title + Summary + Duration (seconds) + More details + Season + Episode # + Video URL + Transcript URL + Chapters URL + Topics + comma, separated, tags + Audio URL + https://…/episode.mp3 + Audio file ready to upload + Add audio file + MP3, M4A, or other audio + Delete episode + Delete this episode? This can\'t be undone. + Your podcast + Add cover art + Square artwork for your show + Show title + My Podcast + Description + Author + Contact email + Website + Categories + Technology, News + Funding links + https://… + Language + en + Copyright + Show type + Episodic + Serial + Explicit content + Show complete (no more episodes) + Locked (premium) + New trailer + Add trailer clip + Short audio or video preview + Trailer title + Media URL + Your podcast + Untitled + No episodes yet. Tap New episode to publish your first one. + Create your podcast + Tap to edit show details + Set up your show\'s title, art, and details + + %1$d trailer + %1$d trailers + + Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. + Add recipient + Add address manually + Add a Nostr user + Search by name or @handle + This user has no Lightning address + Remove recipient + Name (optional) + Lightning address + Node (keysend) + Lightning address + name@example.com + Node pubkey + 02abc… (33-byte hex) + Weight + Fee %1$d episode %1$d episodes @@ -1025,6 +1137,8 @@ Public Bookmarks Repositories Your bookmarked git repositories + Podcasts + Your bookmarked podcasts and episodes Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt new file mode 100644 index 0000000000..36f34e1a39 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt @@ -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) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index f457cb6887..4211bd2509 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt new file mode 100644 index 0000000000..42fdfc1dc9 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -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 ` — 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, + ): Int = + route( + "podcast20", + tail, + "podcast20 ", + 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, + ): 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, + ): 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, + ): 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, + ): 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() + .mapNotNull { Podcasting20PodcastMetadata.parse(it) } + .maxByOrNull { it.event.createdAt } + val episodes = + events + .filterIsInstance() + .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() + .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 = + 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 { + val json = args.flag("value-json") ?: return Result.success(null) + return runCatching { JsonMapper.fromJson(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"))) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt new file mode 100644 index 0000000000..f218a4684c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt @@ -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() + + 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(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 + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 3376178217..5e522ade47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -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), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt index 183f2702bf..71816e68cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt index 42874c0747..fa02e05ec0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt new file mode 100644 index 0000000000..96be4d42e0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -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>, + 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, + 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 = emptyList(), + soundbites: List = emptyList(), + topics: List = emptyList(), + markdownContent: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..bfe6c921bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt @@ -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.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio)) + +fun TagArrayBuilder.video(video: PodcastAudio) = addUnique(VideoTag.assemble(video)) + +fun TagArrayBuilder.episodeNumber(number: Int) = addUnique(EpisodeNumberTag.assemble(number)) + +fun TagArrayBuilder.season(season: Int) = addUnique(SeasonTag.assemble(season)) + +fun TagArrayBuilder.transcript(url: String) = addUnique(TranscriptTag.assemble(url)) + +fun TagArrayBuilder.chapters(url: String) = addUnique(ChaptersTag.assemble(url)) + +fun TagArrayBuilder.value(value: PodcastValue) = addUnique(ValueTag.assemble(value)) + +fun TagArrayBuilder.person(person: PodcastPerson) = add(PersonTag.assemble(person)) + +fun TagArrayBuilder.soundbite(soundbite: PodcastSoundbite) = add(SoundbiteTag.assemble(soundbite)) + +fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) + +fun TagArrayBuilder.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds)) + +fun TagArrayBuilder.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt new file mode 100644 index 0000000000..2eb726e0f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt @@ -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", "", ""]`. + * + * 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): 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 = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(audio: PodcastAudio) = assemble(audio.url, audio.mediaType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt new file mode 100644 index 0000000000..dd24cdf9ab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt @@ -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", ""]`. 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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt new file mode 100644 index 0000000000..4cd243e927 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt new file mode 100644 index 0000000000..cc45a71a81 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt new file mode 100644 index 0000000000..2deea22cc9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt @@ -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", ""]`. 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): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt new file mode 100644 index 0000000000..c07d7edf2c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt new file mode 100644 index 0000000000..32b96c2cd6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt new file mode 100644 index 0000000000..4186262de8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt @@ -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", "", "", "", ""]`. + * + * 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): 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 { + // 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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt new file mode 100644 index 0000000000..123b9f256f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt new file mode 100644 index 0000000000..eabe9c157f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt new file mode 100644 index 0000000000..cc104dca99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt @@ -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` 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): 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 { + val head = arrayOf(TAG_NAME, soundbite.startTimeSeconds.toString(), soundbite.durationSeconds.toString()) + val title = soundbite.title + return if (title.isNullOrEmpty()) head else head + title + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt new file mode 100644 index 0000000000..71ac61f4e8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt new file mode 100644 index 0000000000..996539a264 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt @@ -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", ""]`. */ +class TranscriptTag { + companion object { + const val TAG_NAME = "transcript" + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt new file mode 100644 index 0000000000..6b593e42f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt @@ -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", ""]`, 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): PodcastValue? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return runCatching { JsonMapper.fromJson(tag[1]) }.getOrNull() + } + + fun assemble(value: PodcastValue) = arrayOf(TAG_NAME, JsonMapper.toJson(value)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt new file mode 100644 index 0000000000..f91bc56c6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt @@ -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", "", ""]`. 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): 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 = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(video: PodcastAudio) = assemble(video.url, video.mediaType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt new file mode 100644 index 0000000000..f4a052e78b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt @@ -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 + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt new file mode 100644 index 0000000000..3129ed1edd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -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 = emptyList(), + val explicit: Boolean? = null, + val website: String? = null, + val copyright: String? = null, + val funding: List = emptyList(), + val locked: Boolean? = null, + val type: String? = null, + val complete: Boolean? = null, + val guid: String? = null, + val value: PodcastValue? = null, + val persons: List = 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(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.build(PODCAST_METADATA_D_TAG, JsonMapper.toJson(content), createdAt) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt new file mode 100644 index 0000000000..e9efd02524 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt @@ -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 + * `` 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>, + 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.() -> Unit = {}, + ) = eventTemplate(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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..001925624d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt @@ -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.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.url(url: String) = addUnique(UrlTag.assemble(url)) + +fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) + +fun TagArrayBuilder.length(lengthInBytes: Long) = addUnique(LengthTag.assemble(lengthInBytes)) + +fun TagArrayBuilder.type(mimeType: String) = addUnique(TypeTag.assemble(mimeType)) + +fun TagArrayBuilder.season(season: Int) = addUnique(SeasonTag.assemble(season)) + +fun TagArrayBuilder.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt new file mode 100644 index 0000000000..4d2ceae27e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt new file mode 100644 index 0000000000..0e487621f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt new file mode 100644 index 0000000000..0c369e7ff8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt new file mode 100644 index 0000000000..1cd51f9252 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt @@ -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", ""]`. */ +class UrlTag { + companion object { + const val TAG_NAME = "url" + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt new file mode 100644 index 0000000000..4a76869e15 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt @@ -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", "", ""]` 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, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt new file mode 100644 index 0000000000..f45bb32a82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt @@ -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 + * (). + * + * 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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt new file mode 100644 index 0000000000..22b21fdd88 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt @@ -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 = emptyList(), +) { + companion object { + /** Lenient parse of a chapters.json body; returns null on malformed input. */ + fun parse(json: String): PodcastChapters? = runCatching { JsonMapper.fromJson(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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt new file mode 100644 index 0000000000..c0fd1cc3d6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt @@ -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 + + /** 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 = 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 = emptyList() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt new file mode 100644 index 0000000000..2ed1ca1e55 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt @@ -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 `` 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 + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt new file mode 100644 index 0000000000..dcf87a2f8a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt @@ -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 + + /** + * 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 = emptyList() + + /** Donation/funding page URLs (Podcasting 2.0 `funding`), possibly empty. */ + fun showFundingUrls(): List = 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 = emptyList() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt new file mode 100644 index 0000000000..e88cb2fbc8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt @@ -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.quartz.podcasts + +import androidx.compose.runtime.Immutable + +/** + * A Podcasting-2.0 `podcast:soundbite` — a highlight clip of an episode, defined as a + * [startTimeSeconds] offset into the episode audio and a [durationSeconds] length, with an optional + * [title]. Players surface these as tappable "jump to the good part" chips. + * + * Carried as `["soundbite", "", "", ""]` tags on the episode + * event; times are in seconds and may be fractional. + */ +@Immutable +class PodcastSoundbite( + val startTimeSeconds: Double, + val durationSeconds: Double, + val title: String? = null, +) { + /** Start offset in whole milliseconds, ready for a media player `seekTo`. */ + fun startMillis(): Long = (startTimeSeconds * 1000).toLong() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt new file mode 100644 index 0000000000..755cbc6f47 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt @@ -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.podcasts + +/** + * Accumulates *played* time for Podcasting-2.0 per-minute "streaming" payments and tells the caller + * how many whole minutes are now billable. + * + * The safety contract — a listener must never be charged for time they did not actually listen to — + * lives here, kept pure so it can be unit-tested away from the audio stack: + * + * - Only time the caller reports via [accrue] counts. The caller feeds it elapsed time *only while + * audio is genuinely playing*; paused/stopped/backgrounded time is simply never accrued. + * - Only **whole** minutes are ever billed. A partial minute stays pending and is dropped on the + * floor if the session ends (just discard the instance) — so an interrupted minute is free, never + * rounded up. + */ +class PodcastStreamingAccrual( + private val minuteMillis: Long = 60_000L, +) { + private var pendingMillis = 0L + + /** + * Adds [playingMillis] of genuinely-played time and returns the number of whole minutes that + * just became billable (already subtracted from the pending remainder). Non-positive input is + * ignored and returns 0. + */ + fun accrue(playingMillis: Long): Int { + if (playingMillis <= 0L) return 0 + pendingMillis += playingMillis + val minutes = (pendingMillis / minuteMillis).toInt() + pendingMillis -= minutes * minuteMillis + return minutes + } + + /** Played time accrued toward the next minute but not yet billed. Dropped if the session ends. */ + fun pendingMillis(): Long = pendingMillis +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt new file mode 100644 index 0000000000..5f5cf7c270 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt @@ -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.quartz.podcasts + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +/** + * Podcasting-2.0 value-for-value (V4V) block — how a podcast splits incoming sats across its + * participants. Carried as a JSON object: nested under `value` in the show's `kind:30078` metadata, + * and serialized into the episode's `["value", ""]` tag (where an episode override also sets + * [enabled]). + * + * This is the parsed data model; performing the actual Lightning splits (keysend to `node` + * recipients, LNURL-pay to `lnaddress` recipients, weighted by [PodcastValueRecipient.split]) is a + * separate wallet/NWC concern and is not done here. + */ +@Immutable +@Serializable +class PodcastValue( + /** Episode-level override switch; absent/null on show-level value blocks. */ + val enabled: Boolean? = null, + /** Suggested amount (per the spec, typically per-minute streaming), in [currency] units. */ + val amount: Long? = null, + /** Currency of [amount], e.g. "sat" or "USD". */ + val currency: String? = null, + val recipients: List = emptyList(), +) { + /** Sum of recipient splits, used to turn each [PodcastValueRecipient.split] into a share. */ + fun totalSplit(): Int = recipients.sumOf { it.split } + + /** + * Splits [totalMilliSats] across the recipients per the Podcasting-2.0 value rules and returns + * the non-zero shares (recipient + amount in millisats), preserving recipient order. + * + * - A recipient with [PodcastValueRecipient.fee] = true takes its [PodcastValueRecipient.split] + * as a **percentage of the total**, off the top (e.g. an app/host fee). + * - The remainder is divided among the non-fee recipients **by relative weight** + * ([PodcastValueRecipient.split] / sum of non-fee splits). + * + * Recipients without a payable [PodcastValueRecipient.address] or with a non-positive split are + * ignored. Integer division floors each share, so a few millisats may go unallocated (dust) — + * acceptable for value-for-value streaming. + */ + fun computeShares(totalMilliSats: Long): List { + if (totalMilliSats <= 0) return emptyList() + + val active = recipients.filter { it.split > 0 && !it.address.isNullOrBlank() } + if (active.isEmpty()) return emptyList() + + var feeTotalMillis = 0L + val feeAmounts = HashMap() + for (recipient in active) { + if (recipient.fee == true) { + val millis = totalMilliSats * recipient.split / 100 + if (millis > 0) { + feeAmounts[recipient] = millis + feeTotalMillis += millis + } + } + } + + val remainder = (totalMilliSats - feeTotalMillis).coerceAtLeast(0) + val sharedWeight = active.filter { it.fee != true }.sumOf { it.split } + + val shares = ArrayList(active.size) + for (recipient in active) { + val millis = + if (recipient.fee == true) { + feeAmounts[recipient] ?: 0L + } else if (sharedWeight > 0 && remainder > 0) { + remainder * recipient.split / sharedWeight + } else { + 0L + } + if (millis > 0) shares.add(PodcastValueShare(recipient, millis)) + } + return shares + } + + companion object { + /** + * TLV record type for the Podcasting-2.0 keysend metadata blob (the "boostagram"), a JSON + * object carrying podcast/episode/app/value context. Registered value, used by the whole + * Podcasting-2.0 ecosystem. See . + */ + const val PODCAST_TLV_RECORD: Long = 7629169L + + /** Recipient [PodcastValueRecipient.type] for a keysend to a raw Lightning node pubkey. */ + const val TYPE_NODE = "node" + + /** Recipient [PodcastValueRecipient.type] for an LNURL-pay to a lightning address. */ + const val TYPE_LNADDRESS = "lnaddress" + } +} + +/** One recipient's resolved share of a [PodcastValue] split, in millisats. */ +@Immutable +class PodcastValueShare( + val recipient: PodcastValueRecipient, + val amountMilliSats: Long, +) + +/** One destination in a [PodcastValue] split. */ +@Immutable +@Serializable +class PodcastValueRecipient( + val name: String? = null, + /** "node" (keysend to a node pubkey) or "lnaddress" (LNURL-pay). */ + val type: String? = null, + /** The node pubkey or lightning address, per [type]. */ + val address: String? = null, + /** Relative weight of this recipient's share (not necessarily a percentage). */ + val split: Int = 0, + val customKey: String? = null, + val customValue: String? = null, + /** When true, this recipient takes its share off the top as a fee before the rest is split. */ + val fee: Boolean? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c3871af281..3e3a3081a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -309,6 +309,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 interface EventBuilder { fun build( @@ -566,6 +568,8 @@ class EventFactory { PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig) AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig) FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig) + Podcasting20EpisodeEvent.KIND -> Podcasting20EpisodeEvent(id, pubKey, createdAt, tags, content, sig) + Podcasting20TrailerEvent.KIND -> Podcasting20TrailerEvent(id, pubKey, createdAt, tags, content, sig) ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt new file mode 100644 index 0000000000..5f0de2fffd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt @@ -0,0 +1,74 @@ +/* + * 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.nipF4Podcasts + +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The cross-check the UI uses to mark a podcast's claimed author "verified": a show (kind:10154) + * names authors via `p` tags (unverified), and an author's own counter-claim (kind:10064) lists the + * podcasts they actually author. Verified iff the author's 10064 lists this podcast's pubkey. + */ +class PodcastAuthorVerificationTest { + private val podcastSigner = + DeterministicSigner("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + private val authorSigner = + DeterministicSigner("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".nsecToKeyPair()) + + @Test + fun `claimed author with a matching 10064 counter-claim verifies`() { + val authorPubkey = authorSigner.pubKey + val metadata = + podcastSigner.sign( + PodcastMetadataEvent.build( + title = "My Show", + image = "https://x/cover.jpg", + description = "desc", + authors = listOf(AuthorTag(authorPubkey, AuthorTag.ROLE_HOST)), + ), + ) + val podcastPubkey = metadata.pubKey + + val claimed = metadata.claimedAuthors().single() + assertEquals(authorPubkey, claimed.pubKey) + assertEquals(AuthorTag.ROLE_HOST, claimed.role) + + // The author's own 10064 lists this podcast -> the show's claim is verified. + val authored = authorSigner.sign(AuthoredPodcastsEvent.build(listOf(UserTag(podcastPubkey)))) + assertTrue(authored.authors(podcastPubkey)) + } + + @Test + fun `author whose 10064 omits the podcast is not verified`() { + val unrelated = "0000000000000000000000000000000000000000000000000000000000000001" + val authored = authorSigner.sign(AuthoredPodcastsEvent.build(listOf(UserTag(unrelated)))) + assertFalse(authored.authors(podcastSigner.pubKey)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt new file mode 100644 index 0000000000..4013c30977 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt @@ -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.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Verifies the fingerprint used to drop the "Mock Podcast" spam flood, and that it spares real shows. */ +class PodcastMockSpamTest { + private fun event( + title: String?, + description: String?, + content: String, + ): PodcastMetadataEvent { + val tags = + buildList { + title?.let { add(arrayOf("title", it)) } + description?.let { add(arrayOf("description", it)) } + }.toTypedArray() + return PodcastMetadataEvent(EMPTY_ID, EMPTY_ID, 0, tags, content, EMPTY_ID) + } + + @Test + fun `the exact mock spam structure matches`() { + assertTrue(event("Mock Podcast", "Headless test feed", "Headless test feed").isMockSpam()) + } + + @Test + fun `real shows are not flagged`() { + // A genuine show that happens to share none of the three fields. + assertFalse(event("My Real Podcast", "A show about things", "Welcome!").isMockSpam()) + // Same title only — not enough. + assertFalse(event("Mock Podcast", "A different description", "Real content").isMockSpam()) + // Same content only — not enough. + assertFalse(event("Another Show", "Another desc", "Headless test feed").isMockSpam()) + // Missing tags entirely. + assertFalse(event(null, null, "Headless test feed").isMockSpam()) + } + + companion object { + private val EMPTY_ID: HexKey = "0".repeat(64) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt new file mode 100644 index 0000000000..74fec4309a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt @@ -0,0 +1,145 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastPersonSoundbiteTest { + @Test + fun `person tag parses all fields`() { + val person = PersonTag.parse(arrayOf("person", "Alice", "host", "https://img/a.png", "https://alice.example")) + assertEquals("Alice", person?.name) + assertEquals("host", person?.role) + assertEquals("https://img/a.png", person?.img) + assertEquals("https://alice.example", person?.href) + } + + @Test + fun `person tag with only a name`() { + val person = PersonTag.parse(arrayOf("person", "Bob")) + assertEquals("Bob", person?.name) + assertNull(person?.role) + assertNull(person?.img) + assertNull(person?.href) + } + + @Test + fun `person tag keeps href in slot 4 when role and img are blank`() { + val person = PersonTag.parse(arrayOf("person", "Carol", "", "", "https://carol.example")) + assertEquals("Carol", person?.name) + assertNull(person?.role) + assertNull(person?.img) + assertEquals("https://carol.example", person?.href) + } + + @Test + fun `person tag round-trips through assemble`() { + val original = PodcastPerson(name = "Dave", role = "guest", img = "https://img/d.png") + val reparsed = PersonTag.parse(PersonTag.assemble(original)) + assertEquals("Dave", reparsed?.name) + assertEquals("guest", reparsed?.role) + assertEquals("https://img/d.png", reparsed?.img) + assertNull(reparsed?.href) + } + + @Test + fun `nameless person tag is dropped`() { + assertNull(PersonTag.parse(arrayOf("person"))) + assertNull(PersonTag.parse(arrayOf("person", ""))) + } + + @Test + fun `person href resolves an npub to a pubkey`() { + val npub = "npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y" + val hex = "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365" + assertEquals(hex, PodcastPerson(name = "Alice", href = npub).nostrPubKey()) + assertEquals(hex, PodcastPerson(name = "Alice", href = "nostr:$npub").nostrPubKey()) + } + + @Test + fun `person href that is a plain web link has no pubkey`() { + assertNull(PodcastPerson(name = "Alice", href = "https://alice.example").nostrPubKey()) + assertNull(PodcastPerson(name = "Alice", href = null).nostrPubKey()) + } + + @Test + fun `soundbite tag parses times and optional title`() { + val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "73.5", "60.0", "Best moment")) + assertEquals(73.5, soundbite?.startTimeSeconds) + assertEquals(60.0, soundbite?.durationSeconds) + assertEquals("Best moment", soundbite?.title) + assertEquals(73500L, soundbite?.startMillis()) + } + + @Test + fun `soundbite tag without title`() { + val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "0", "30")) + assertEquals(0.0, soundbite?.startTimeSeconds) + assertEquals(30.0, soundbite?.durationSeconds) + assertNull(soundbite?.title) + } + + @Test + fun `soundbite with bad or non-positive numbers is dropped`() { + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "abc", "60"))) + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "10", "0"))) + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "10"))) + } + + @Test + fun `soundbite round-trips through assemble`() { + val reparsed = SoundbiteTag.parse(SoundbiteTag.assemble(PodcastSoundbite(12.0, 45.0, "Clip"))) + assertEquals(12.0, reparsed?.startTimeSeconds) + assertEquals(45.0, reparsed?.durationSeconds) + assertEquals("Clip", reparsed?.title) + } + + @Test + fun `show metadata JSON parses a persons array`() { + val json = + """{"title":"My Show","persons":[{"name":"Alice","role":"host","img":"https://img/a.png"},{"name":"Bob","role":"guest"}]}""" + val event = AppSpecificDataEvent("id", "pk", 0, arrayOf(arrayOf("d", "podcast-metadata")), json, "sig") + val show = Podcasting20PodcastMetadata.parse(event) + val persons = show?.showPersons().orEmpty() + assertEquals(2, persons.size) + assertEquals("Alice", persons[0].name) + assertEquals("host", persons[0].role) + assertEquals("https://img/a.png", persons[0].img) + assertEquals("Bob", persons[1].name) + assertEquals("guest", persons[1].role) + } + + @Test + fun `show metadata without persons is empty rather than a crash`() { + val event = AppSpecificDataEvent("id", "pk", 0, arrayOf(arrayOf("d", "podcast-metadata")), """{"title":"Bare"}""", "sig") + val show = Podcasting20PodcastMetadata.parse(event) + assertTrue(show?.showPersons().orEmpty().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt new file mode 100644 index 0000000000..5eeb714da2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt @@ -0,0 +1,219 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.edit +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Podcasting20EpisodeEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 30054`() { + assertEquals(30054, Podcasting20EpisodeEvent.KIND) + } + + @Test + fun `build round-trips fields and is addressable by d tag`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "episode-1699123456-abc123def", + title = "The Future of Decentralized Social Media", + audios = listOf(PodcastAudio("https://example.com/episodes/episode-001.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + description = "A deep dive into how protocols like Nostr are changing social media", + image = "https://example.com/artwork/episode-001.jpg", + durationInSeconds = 3600, + topics = listOf("technology", "decentralization"), + markdownContent = "In this episode, we discuss decentralized social media.", + ) + val event = signer.sign(template) + + assertEquals("episode-1699123456-abc123def", event.dTag()) + assertEquals("The Future of Decentralized Social Media", event.title()) + assertEquals("A deep dive into how protocols like Nostr are changing social media", event.description()) + assertEquals("https://example.com/artwork/episode-001.jpg", event.image()) + assertEquals(3600L, event.durationInSeconds()) + assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate()) + assertEquals("In this episode, we discuss decentralized social media.", event.content) + + val audios = event.audios() + assertEquals(1, audios.size) + assertEquals("https://example.com/episodes/episode-001.mp3", audios[0].url) + assertEquals("audio/mpeg", audios[0].mediaType) + + assertTrue(event.topics().containsAll(listOf("technology", "decentralization"))) + assertEquals("Podcast episode: The Future of Decentralized Social Media", event.alt()) + } + + @Test + fun `optional fields default to null`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-2", + title = "No extras", + audios = listOf(PodcastAudio("https://example.com/ep2.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) + val event = signer.sign(template) + + assertNull(event.image()) + assertNull(event.description()) + assertNull(event.durationInSeconds()) + assertNull(event.editsEventId()) + assertNull(event.video()) + assertNull(event.number()) + assertNull(event.season()) + assertNull(event.transcriptUrl()) + assertNull(event.chaptersUrl()) + assertNull(event.value()) + } + + @Test + fun `value-for-value split round-trips through the value tag and the interface`() { + val value = + PodcastValue( + enabled = true, + amount = 1000, + currency = "sat", + recipients = + listOf( + PodcastValueRecipient(name = "Host", type = "lnaddress", address = "host@ln.tips", split = 90), + PodcastValueRecipient(name = "Producer", type = "node", address = "02abcd", split = 10, fee = true), + ), + ) + val episode: PodcastEpisode = + signer.sign( + Podcasting20EpisodeEvent.build( + dTag = "ep-v4v", + title = "V4V", + audios = listOf(PodcastAudio("https://x/a.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + value = value, + ), + ) + + val parsed = episode.episodeValue() + assertTrue(parsed != null) + assertEquals(true, parsed.enabled) + assertEquals("sat", parsed.currency) + assertEquals(100, parsed.totalSplit()) + assertEquals(2, parsed.recipients.size) + assertEquals("Host", parsed.recipients[0].name) + assertEquals("lnaddress", parsed.recipients[0].type) + assertEquals("host@ln.tips", parsed.recipients[0].address) + assertEquals(90, parsed.recipients[0].split) + assertEquals(true, parsed.recipients[1].fee) + } + + @Test + fun `rich Podcasting 2 point 0 tags round-trip and surface through the interface`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-rich", + title = "Rich Episode", + audios = listOf(PodcastAudio("https://example.com/ep.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + video = PodcastAudio("https://example.com/ep.mp4", "video/mp4"), + episodeNumber = 5, + season = 2, + transcriptUrl = "https://example.com/ep.srt", + chaptersUrl = "https://example.com/ep.chapters.json", + ) + val episode: PodcastEpisode = signer.sign(template) + + assertEquals("https://example.com/ep.mp4", episode.episodeVideo()?.url) + assertEquals("video/mp4", episode.episodeVideo()?.mediaType) + assertEquals(5, episode.episodeNumber()) + assertEquals(2, episode.episodeSeason()) + assertEquals("https://example.com/ep.srt", episode.episodeTranscriptUrl()) + assertEquals("https://example.com/ep.chapters.json", episode.episodeChaptersUrl()) + // Audio still comes through independently of the video source. + assertEquals("https://example.com/ep.mp3", episode.episodeAudio().single().url) + } + + @Test + fun `edit tag tracks the original event id`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-3", + title = "Corrected", + audios = listOf(PodcastAudio("https://example.com/ep3.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) { + edit("abababababababababababababababababababababababababababababababab") + } + val event = signer.sign(template) + + assertEquals("abababababababababababababababababababababababababababababababab", event.editsEventId()) + } + + @Test + fun `parses the spec example json into the typed event via EventFactory`() { + // Verbatim example from derekross/podstr NIP.md (id and sig elided are not + // required for parsing tags; we supply structurally valid placeholders). + val json = + """ + { + "kind": 30054, + "content": "In this episode, we discuss the latest developments in decentralized social media protocols.", + "tags": [ + ["d", "episode-1699123456-abc123def"], + ["title", "The Future of Decentralized Social Media"], + ["audio", "https://example.com/episodes/episode-001.mp3", "audio/mpeg"], + ["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"], + ["alt", "Podcast episode: The Future of Decentralized Social Media"], + ["description", "A deep dive into how protocols like Nostr are changing social media"], + ["image", "https://example.com/artwork/episode-001.jpg"], + ["duration", "3600"], + ["t", "technology"], + ["t", "decentralization"], + ["t", "social-media"] + ], + "created_at": 1699123456, + "pubkey": "0000000000000000000000000000000000000000000000000000000000000001", + "id": "0000000000000000000000000000000000000000000000000000000000000002", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + """.trimIndent() + + val event = Event.fromJson(json) + assertTrue(event is Podcasting20EpisodeEvent) + assertEquals("The Future of Decentralized Social Media", event.title()) + assertEquals(3600L, event.durationInSeconds()) + assertEquals(3, event.topics().size) + assertEquals("https://example.com/episodes/episode-001.mp3", event.audios()[0].url) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt new file mode 100644 index 0000000000..774caf9533 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt @@ -0,0 +1,181 @@ +/* + * 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 + +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.isPodcastShowEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow +import com.vitorpamplona.quartz.podcasts.PodcastShow +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Podcasting20PodcastMetadataTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + // Verbatim content shape from derekross/podstr (kind 30078, d="podcast-metadata"). + private val metadataJson = + """ + { + "title": "My Podcast", + "description": "A podcast about interesting topics", + "author": "John Doe", + "email": "john@example.com", + "image": "https://example.com/artwork.jpg", + "language": "en", + "categories": ["Technology", "Science"], + "explicit": false, + "website": "https://example.com", + "copyright": "© 2025 John Doe", + "funding": ["https://example.com/donate", "https://example.com/tip"], + "locked": false, + "value": { + "amount": 100000, + "currency": "sat", + "recipients": [ + { "name": "Host", "type": "lnaddress", "address": "host@example.com", "split": 100 } + ] + }, + "type": "episodic", + "complete": true, + "guid": "abc-123" + } + """.trimIndent() + + private fun appDataEvent( + dTag: String, + content: String, + ): AppSpecificDataEvent = signer.sign(AppSpecificDataEvent.build(dTag = dTag, description = content)) + + @Test + fun `parses podcast-metadata json into PodcastShow fields`() { + val event = appDataEvent("podcast-metadata", metadataJson) + val show = Podcasting20PodcastMetadata.parse(event) + + assertTrue(show != null) + assertEquals("My Podcast", show.showTitle()) + assertEquals("A podcast about interesting topics", show.showDescription()) + assertEquals("https://example.com/artwork.jpg", show.showImage()) + assertEquals(listOf("https://example.com"), show.showWebsites()) + assertEquals("John Doe", show.showAuthor()) + assertEquals(listOf("Technology", "Science"), show.showCategories()) + assertEquals(listOf("https://example.com/donate", "https://example.com/tip"), show.showFundingUrls()) + assertEquals("© 2025 John Doe", show.showCopyright()) + assertFalse(show.showIsExplicit()) + assertTrue(show.showIsComplete()) + assertEquals("en", show.language()) + assertEquals("john@example.com", show.email()) + assertEquals("episodic", show.type()) + assertEquals("abc-123", show.guid()) + assertFalse(show.isLocked()) + + val value = show.showValue() + assertTrue(value != null) + assertEquals("sat", value.currency) + assertEquals(1, value.recipients.size) + assertEquals("Host", value.recipients[0].name) + assertEquals("host@example.com", value.recipients[0].address) + assertEquals(100, value.recipients[0].split) + } + + @Test + fun `build round-trips a Content through sign and parse`() { + val content = + Podcasting20PodcastMetadata.Content( + title = "Built Show", + description = "made in quartz", + author = "Jane", + image = "https://example.com/c.jpg", + categories = listOf("Tech"), + explicit = true, + funding = listOf("https://example.com/donate"), + complete = false, + ) + val event = signer.sign(Podcasting20PodcastMetadata.build(content)) + + assertEquals("podcast-metadata", event.dTag()) + val parsed = Podcasting20PodcastMetadata.parse(event) + assertTrue(parsed != null) + assertEquals("Built Show", parsed.showTitle()) + assertEquals("Jane", parsed.showAuthor()) + assertEquals(listOf("Tech"), parsed.showCategories()) + assertEquals(listOf("https://example.com/donate"), parsed.showFundingUrls()) + assertTrue(parsed.showIsExplicit()) + assertFalse(parsed.showIsComplete()) + } + + @Test + fun `optional rich fields default to empty or null when absent`() { + val event = appDataEvent("podcast-metadata", """{"title":"Bare","description":"d","image":"i"}""") + val show = Podcasting20PodcastMetadata.parse(event) + + assertTrue(show != null) + assertNull(show.showAuthor()) + assertTrue(show.showCategories().isEmpty()) + assertTrue(show.showFundingUrls().isEmpty()) + assertNull(show.showCopyright()) + assertFalse(show.showIsExplicit()) + assertFalse(show.showIsComplete()) + } + + @Test + fun `ignores app-data events with a different d tag`() { + val event = appDataEvent("amethyst-settings", metadataJson) + assertNull(Podcasting20PodcastMetadata.parse(event)) + assertFalse(isPodcastShowEvent(event)) + } + + @Test + fun `returns null when content is not valid metadata json`() { + val event = appDataEvent("podcast-metadata", "not-json") + assertNull(Podcasting20PodcastMetadata.parse(event)) + } + + @Test + fun `resolver unifies both show kinds into one list`() { + val f4 = + signer.sign( + PodcastMetadataEvent.build( + title = "F4 Show", + image = "https://example.com/f4.png", + description = "NIP-F4 show", + websites = listOf("https://f4.example.com"), + ), + ) + val pc20 = appDataEvent("podcast-metadata", metadataJson) + + val shows: List = listOf(f4, pc20).mapNotNull { resolvePodcastShow(it) } + + assertEquals(2, shows.size) + assertEquals(listOf("F4 Show", "My Podcast"), shows.map { it.showTitle() }) + assertTrue(isPodcastShowEvent(f4)) + assertTrue(isPodcastShowEvent(pc20)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt new file mode 100644 index 0000000000..d0e0de60f8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt @@ -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.quartz.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class Podcasting20TrailerEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 30055`() { + assertEquals(30055, Podcasting20TrailerEvent.KIND) + } + + @Test + fun `build round-trips fields`() { + val template = + Podcasting20TrailerEvent.build( + dTag = "trailer-1699123456-xyz789abc", + title = "Season 2 Preview", + url = "https://example.com/trailers/season-2-preview.mp3", + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + lengthInBytes = 1024000, + mimeType = "audio/mpeg", + season = 2, + ) + val event = signer.sign(template) + + assertEquals("trailer-1699123456-xyz789abc", event.dTag()) + assertEquals("Season 2 Preview", event.title()) + assertEquals("https://example.com/trailers/season-2-preview.mp3", event.url()) + assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate()) + assertEquals(1024000L, event.lengthInBytes()) + assertEquals("audio/mpeg", event.mimeType()) + assertEquals(2, event.season()) + // Per spec the content SHOULD carry the trailer title. + assertEquals("Season 2 Preview", event.content) + } + + @Test + fun `optional fields default to null`() { + val template = + Podcasting20TrailerEvent.build( + dTag = "trailer-min", + title = "Teaser", + url = "https://example.com/teaser.mp3", + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) + val event = signer.sign(template) + + assertNull(event.lengthInBytes()) + assertNull(event.mimeType()) + assertNull(event.season()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt new file mode 100644 index 0000000000..da9e03d261 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt @@ -0,0 +1,66 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PodcastBoostagramTest { + @Test + fun `uses satoshis-stream field names and omits unset fields`() { + val json = + PodcastBoostagram( + podcast = "My Show", + episode = "Ep 1", + action = PodcastBoostagram.ACTION_BOOST, + appName = "Amethyst", + valueMsatTotal = 21_000_000L, + ).toJson() + + assertTrue(json.contains("\"podcast\":\"My Show\"")) + assertTrue(json.contains("\"app_name\":\"Amethyst\"")) + assertTrue(json.contains("\"value_msat_total\":21000000")) + assertTrue(json.contains("\"action\":\"boost\"")) + // Unset optionals (message, sender_name) must not appear. + assertFalse(json.contains("message")) + assertFalse(json.contains("sender_name")) + } + + @Test + fun `round-trips through json`() { + val original = + PodcastBoostagram( + podcast = "Show", + action = PodcastBoostagram.ACTION_STREAM, + valueMsatTotal = 1000L, + senderName = "alice", + ) + val parsed = JsonMapper.fromJson(original.toJson()) + + assertEquals("Show", parsed.podcast) + assertEquals(PodcastBoostagram.ACTION_STREAM, parsed.action) + assertEquals(1000L, parsed.valueMsatTotal) + assertEquals("alice", parsed.senderName) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt new file mode 100644 index 0000000000..470e2e310f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt @@ -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.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastChaptersTest { + @Test + fun `parses a podcast-namespace chapters json`() { + val json = + """ + { + "version": "1.2.0", + "chapters": [ + { "startTime": 0, "title": "Intro" }, + { "startTime": 73.5, "title": "Topic One", "img": "https://x/c1.jpg", "url": "https://x/ref" }, + { "startTime": 600, "title": "Wrap-up", "toc": false } + ] + } + """.trimIndent() + + val parsed = PodcastChapters.parse(json) + assertTrue(parsed != null) + assertEquals("1.2.0", parsed.version) + assertEquals(3, parsed.chapters.size) + assertEquals("Intro", parsed.chapters[0].title) + assertEquals(0L, parsed.chapters[0].startSeconds()) + assertEquals(73L, parsed.chapters[1].startSeconds()) + assertEquals("https://x/c1.jpg", parsed.chapters[1].img) + assertEquals(false, parsed.chapters[2].toc) + } + + @Test + fun `returns null on malformed json`() { + assertNull(PodcastChapters.parse("not-json")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt new file mode 100644 index 0000000000..8333f24373 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt @@ -0,0 +1,91 @@ +/* + * 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.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies that NIP-22 (kind:1111) comments scope correctly onto podcast episodes of both drafts. + * The app's comment composer always routes through [CommentEvent.replyBuilder], so this exercises + * exactly the path a "reply to this episode" tap takes — proving comments already work end-to-end. + */ +class PodcastCommentScopeTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `episode events declare themselves as NIP-22 comment roots`() { + // Consistency with every other commentable content type (articles, videos, …). + val pc20 = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "E", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val f4 = + signer.sign( + PodcastEpisodeEvent.build("E", "d", listOf(AudioTag("https://x/a.mp3"))), + ) + assertTrue(pc20 is RootScope) + assertTrue(f4 is RootScope) + } + + @Test + fun `comment on a Podcasting 2 point 0 episode roots on its address`() { + val episode = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "Episode", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Addressable root: the comment carries the episode's `a`/`A` address (30054:pubkey:d). + assertTrue(comment.rootAddressIds().contains(episode.addressTag())) + assertTrue(comment.hasRootScopeKind(Podcasting20EpisodeEvent.KIND.toString())) + assertTrue(comment.rootEventIds().contains(episode.id)) + } + + @Test + fun `comment on a NIP-F4 episode roots on its event id`() { + val episode = + signer.sign( + PodcastEpisodeEvent.build("Episode", "notes", listOf(AudioTag("https://x/a.mp3"))), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Regular (non-addressable) root: scoped by event id via the `e`/`E` tag, no address root. + assertTrue(comment.rootEventIds().contains(episode.id)) + assertTrue(comment.hasRootScopeKind(PodcastEpisodeEvent.KIND.toString())) + assertTrue(comment.rootAddressIds().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt new file mode 100644 index 0000000000..2a5df005c3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt @@ -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.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PodcastStreamingAccrualTest { + @Test + fun `bills a whole minute only once it is fully played`() { + val accrual = PodcastStreamingAccrual() + assertEquals(0, accrual.accrue(30_000L)) // 30s played -> nothing billable yet + assertEquals(30_000L, accrual.pendingMillis()) + assertEquals(1, accrual.accrue(30_000L)) // another 30s -> one full minute + assertEquals(0L, accrual.pendingMillis()) + } + + @Test + fun `carries the partial remainder forward instead of rounding up`() { + val accrual = PodcastStreamingAccrual() + // 1m 25s played in one go -> bill 1 minute, keep 25s pending. + assertEquals(1, accrual.accrue(85_000L)) + assertEquals(25_000L, accrual.pendingMillis()) + // 35s more completes the second minute. + assertEquals(1, accrual.accrue(35_000L)) + assertEquals(0L, accrual.pendingMillis()) + } + + @Test + fun `many small playing ticks accumulate to a minute`() { + val accrual = PodcastStreamingAccrual() + var billed = 0 + repeat(59) { billed += accrual.accrue(1_000L) } + assertEquals(0, billed) // 59s, still short + billed += accrual.accrue(1_000L) + assertEquals(1, billed) // 60th second tips it over + } + + @Test + fun `a single long interval bills every full minute it contains`() { + val accrual = PodcastStreamingAccrual() + assertEquals(3, accrual.accrue(190_000L)) // 3m 10s -> 3 minutes + assertEquals(10_000L, accrual.pendingMillis()) + } + + @Test + fun `non-positive ticks never bill and an abandoned partial minute is simply dropped`() { + val accrual = PodcastStreamingAccrual() + assertEquals(0, accrual.accrue(0L)) + assertEquals(0, accrual.accrue(-5_000L)) + assertEquals(0, accrual.accrue(40_000L)) // 40s pending + assertEquals(40_000L, accrual.pendingMillis()) + // Session ends here: the caller discards the instance, so the 40s is never charged. + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt new file mode 100644 index 0000000000..391fac4952 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt @@ -0,0 +1,93 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PodcastValueShareTest { + private fun node( + name: String, + split: Int, + fee: Boolean? = null, + ) = PodcastValueRecipient(name = name, type = PodcastValue.TYPE_NODE, address = "node-$name", split = split, fee = fee) + + @Test + fun `weighted split with no fees divides by relative weight`() { + val value = + PodcastValue( + recipients = listOf(node("host", 90), node("producer", 10)), + ) + // 100k sats total in millisats. + val shares = value.computeShares(100_000_000L).associate { it.recipient.name to it.amountMilliSats } + + assertEquals(90_000_000L, shares["host"]) + assertEquals(10_000_000L, shares["producer"]) + } + + @Test + fun `fee recipient takes its split as a percent off the top then remainder split by weight`() { + val value = + PodcastValue( + recipients = + listOf( + node("app", 5, fee = true), // 5% fee off the top + node("host", 80), + node("cohost", 20), + ), + ) + val shares = value.computeShares(1_000_000L).associate { it.recipient.name to it.amountMilliSats } + + // 5% of 1,000,000 = 50,000 fee. Remainder 950,000 split 80/20. + assertEquals(50_000L, shares["app"]) + assertEquals(760_000L, shares["host"]) + assertEquals(190_000L, shares["cohost"]) + // No more than the total is ever allocated. + assertTrue(shares.values.sum() <= 1_000_000L) + } + + @Test + fun `recipients without an address or with non-positive split are ignored`() { + val value = + PodcastValue( + recipients = + listOf( + node("host", 100), + PodcastValueRecipient(name = "noaddr", type = PodcastValue.TYPE_NODE, address = null, split = 50), + node("zero", 0), + ), + ) + val shares = value.computeShares(10_000L) + + assertEquals(1, shares.size) + assertEquals("host", shares.single().recipient.name) + assertEquals(10_000L, shares.single().amountMilliSats) + } + + @Test + fun `non-positive total or empty recipients yields no shares`() { + val value = PodcastValue(recipients = listOf(node("host", 100))) + assertTrue(value.computeShares(0L).isEmpty()) + assertTrue(value.computeShares(-5L).isEmpty()) + assertTrue(PodcastValue(recipients = emptyList()).computeShares(1_000L).isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt new file mode 100644 index 0000000000..8f7d5cd45e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt @@ -0,0 +1,88 @@ +/* + * 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.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Proves the merge: a NIP-F4 episode (`kind:54`) and a Podcasting-2.0 episode + * (`kind:30054`) — built on different identity models and event kinds — flow into + * a single `List` and expose the same fields through the shared + * abstraction. + */ +class UnifiedPodcastEpisodeTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `both kinds satisfy the shared PodcastEpisode abstraction`() { + val f4 = + signer.sign( + PodcastEpisodeEvent.build( + title = "F4 Episode", + description = "Published under NIP-F4", + audios = listOf(AudioTag("https://example.com/f4.mp3", "audio/mpeg")), + image = "https://example.com/f4.png", + createdAt = 1000, + ), + ) + + val pc20 = + signer.sign( + Podcasting20EpisodeEvent.build( + dTag = "ep-1", + title = "Podcasting 2.0 Episode", + audios = listOf(PodcastAudio("https://example.com/pc20.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + description = "Published under kind 30054", + image = "https://example.com/pc20.png", + durationInSeconds = 1800, + createdAt = 2000, + ), + ) + + // The whole point: one list, mixed kinds, ordered newest-first. + val unified: List = listOf(f4, pc20).sortedByDescending { it.episodePublishedAt() } + + assertEquals(listOf("Podcasting 2.0 Episode", "F4 Episode"), unified.map { it.episodeTitle() }) + + unified.forEach { episode -> + assertEquals("audio/mpeg", episode.episodeAudio().single().mediaType) + } + + // F4 has no duration tag; the Podcasting-2.0 episode carries one. + val byTitle = unified.associateBy { it.episodeTitle() } + assertNull(byTitle["F4 Episode"]!!.episodeDurationInSeconds()) + assertEquals(1800L, byTitle["Podcasting 2.0 Episode"]!!.episodeDurationInSeconds()) + + assertEquals(1000L, byTitle["F4 Episode"]!!.episodePublishedAt()) + assertEquals(2000L, byTitle["Podcasting 2.0 Episode"]!!.episodePublishedAt()) + } +}