mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3441 from vitorpamplona/claude/podcast-event-kinds-merge-vv24gd
Add podcast authoring UI and NIP-XX Podcasting 2.0 support
This commit is contained in:
@@ -289,6 +289,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -3752,7 +3754,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
is PodcastMetadataEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
// Drop the known "Mock Podcast" spam flood instead of caching thousands of them.
|
||||
if (event.isMockSpam()) {
|
||||
false
|
||||
} else {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
}
|
||||
|
||||
is AuthoredPodcastsEvent -> {
|
||||
@@ -3763,6 +3770,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is Podcasting20EpisodeEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is Podcasting20TrailerEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValueShare
|
||||
import com.vitorpamplona.quartz.utils.mapNotNullAsync
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Executes a Podcasting-2.0 value-for-value (V4V) split: takes a [PodcastValue] block and a total
|
||||
* amount, computes each recipient's share ([PodcastValue.computeShares]) and pays them.
|
||||
*
|
||||
* This is the V4V analogue of [ZapPaymentHandler]. The recipients are raw Lightning destinations
|
||||
* declared in the value block, but the two kinds are paid very differently:
|
||||
*
|
||||
* - [PodcastValue.TYPE_LNADDRESS] — resolved to a BOLT-11 via LNURL-pay and paid through the user's
|
||||
* default payment source (NWC, CLINK debit, or — when none is set — handed to an external wallet
|
||||
* via [onPayInvoicesViaIntent]). Same rails as a zap, and when `asZap` is set each share also
|
||||
* carries a NIP-57 zap request, so a Nostr-aware lnaddress provider issues a real **zap receipt**
|
||||
* (this is what lets the standard zap button drive a V4V split with its usual icon/counter UI).
|
||||
* Per-minute streaming pays with `asZap = false` to avoid publishing a receipt every minute.
|
||||
* - [PodcastValue.TYPE_NODE] — paid by **keysend** (NIP-47 `pay_keysend`) carrying the Podcasting-2.0
|
||||
* boostagram TLV ([PodcastValue.PODCAST_TLV_RECORD]) plus any per-recipient custom TLV. There is no
|
||||
* LNURL endpoint and no invoice, so keysend can never produce a zap receipt regardless of `asZap`.
|
||||
* Keysend is only available over NWC, so node recipients are skipped (with an error) when no NWC
|
||||
* wallet is set up.
|
||||
*/
|
||||
class V4VPaymentHandler(
|
||||
val account: Account,
|
||||
) {
|
||||
/** A resolved lnaddress share ready to pay: the share plus the BOLT-11 fetched for it. */
|
||||
class InvoicePayable(
|
||||
val share: PodcastValueShare,
|
||||
val invoice: String,
|
||||
)
|
||||
|
||||
suspend fun pay(
|
||||
value: PodcastValue,
|
||||
totalMilliSats: Long,
|
||||
boostagram: PodcastBoostagram,
|
||||
zappedNote: Note?,
|
||||
context: Context,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayInvoicesViaIntent: (invoices: List<String>) -> Unit,
|
||||
asZap: Boolean = false,
|
||||
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val shares = value.computeShares(totalMilliSats)
|
||||
if (shares.isEmpty()) {
|
||||
onError(
|
||||
stringRes(context, R.string.podcast_value_error_title),
|
||||
stringRes(context, R.string.podcast_value_no_recipients),
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val nodeShares = shares.filter { it.recipient.type == PodcastValue.TYPE_NODE }
|
||||
val lnAddressShares = shares.filter { it.recipient.type == PodcastValue.TYPE_LNADDRESS }
|
||||
|
||||
onProgress(0.05f)
|
||||
|
||||
// Keysend (node) recipients can only be paid over NWC.
|
||||
if (nodeShares.isNotEmpty()) {
|
||||
if (account.nip47SignerState.hasWalletConnectSetup()) {
|
||||
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
|
||||
} else {
|
||||
onError(
|
||||
stringRes(context, R.string.podcast_value_error_title),
|
||||
stringRes(context, R.string.podcast_value_keysend_requires_nwc),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (lnAddressShares.isNotEmpty()) {
|
||||
val payables =
|
||||
assembleInvoices(
|
||||
shares = lnAddressShares,
|
||||
message = boostagram.message.orEmpty(),
|
||||
asZap = asZap,
|
||||
zapType = zapType,
|
||||
zappedNote = zappedNote,
|
||||
okHttpClient = okHttpClient,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = { onProgress(it * 0.6f + 0.1f) },
|
||||
)
|
||||
payInvoices(payables, zappedNote, context, onError, onPayInvoicesViaIntent) {
|
||||
onProgress(it * 0.25f + 0.7f)
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(1f)
|
||||
}
|
||||
|
||||
/** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */
|
||||
private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
|
||||
|
||||
private suspend fun payNodeSharesViaKeysend(
|
||||
shares: List<PodcastValueShare>,
|
||||
boostagram: PodcastBoostagram,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
val metadataTlv = TlvRecord(PodcastValue.PODCAST_TLV_RECORD, hexTlv(boostagram.toJson()))
|
||||
|
||||
shares.forEach { share ->
|
||||
val pubkey = share.recipient.address ?: return@forEach
|
||||
|
||||
val tlvRecords = mutableListOf(metadataTlv)
|
||||
val customType = share.recipient.customKey?.toLongOrNull()
|
||||
val customValue = share.recipient.customValue
|
||||
if (customType != null && customValue != null) {
|
||||
tlvRecords.add(TlvRecord(customType, hexTlv(customValue)))
|
||||
}
|
||||
|
||||
val request =
|
||||
PayKeysendMethod.create(
|
||||
amount = share.amountMilliSats,
|
||||
pubkey = pubkey,
|
||||
tlvRecords = tlvRecords,
|
||||
)
|
||||
|
||||
account.sendNwcRequest(request) { response: Response? ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
response.errorMessage()
|
||||
?: stringRes(context, R.string.error_parsing_error_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun assembleInvoices(
|
||||
shares: List<PodcastValueShare>,
|
||||
message: String,
|
||||
asZap: Boolean,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zappedNote: Note?,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
): List<InvoicePayable> {
|
||||
// When paying as a zap, attach a NIP-57 request to each share so the recipient's LNURL
|
||||
// provider mints a zappable invoice and publishes a receipt. The receipt is attributed to
|
||||
// the zapped note (toUser = null) since a value-block lnaddress is a raw payee, not
|
||||
// necessarily a Nostr identity. Send to the show/episode author's inbox so they see it.
|
||||
val noteEvent = zappedNote?.event
|
||||
val authorRelays = zappedNote?.author?.inboxRelays()?.toSet() ?: emptySet()
|
||||
|
||||
var progress = 0f
|
||||
return mapNotNullAsync(shares) { share: PodcastValueShare ->
|
||||
val lnAddress = share.recipient.address ?: return@mapNotNullAsync null
|
||||
try {
|
||||
val nostrRequest =
|
||||
if (asZap && noteEvent != null) {
|
||||
account.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = null,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
toUser = null,
|
||||
additionalRelays = authorRelays,
|
||||
amountMillisats = share.amountMilliSats,
|
||||
lnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val invoice =
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnAddress = lnAddress,
|
||||
milliSats = share.amountMilliSats,
|
||||
message = message,
|
||||
nostrRequest = nostrRequest,
|
||||
okHttpClient = okHttpClient,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
progress += 1f / shares.size
|
||||
onProgress(progress)
|
||||
InvoicePayable(share, invoice)
|
||||
} catch (e: LightningAddressResolver.LightningAddressError) {
|
||||
onError(e.title, e.msg)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
e.message ?: stringRes(context, R.string.error_parsing_error_message),
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun payInvoices(
|
||||
payables: List<InvoicePayable>,
|
||||
zappedNote: Note?,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
onPayInvoicesViaIntent: (List<String>) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
) {
|
||||
if (payables.isEmpty()) return
|
||||
|
||||
when (val source = account.settings.defaultPaymentSource()) {
|
||||
is PaymentSource.Nwc -> {
|
||||
var done = 0
|
||||
payables.forEach { payable ->
|
||||
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
response.errorMessage()
|
||||
?: stringRes(context, R.string.error_parsing_error_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
done++
|
||||
onProgress(done.toFloat() / payables.size)
|
||||
}
|
||||
}
|
||||
|
||||
is PaymentSource.ClinkDebit -> {
|
||||
var done = 0
|
||||
payables.forEach { payable ->
|
||||
val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice)
|
||||
if (response?.isOk() != true) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
response?.failureDetail()
|
||||
?: stringRes(context, R.string.clink_debit_no_response),
|
||||
)
|
||||
}
|
||||
done++
|
||||
onProgress(done.toFloat() / payables.size)
|
||||
}
|
||||
}
|
||||
|
||||
null -> {
|
||||
onPayInvoicesViaIntent(payables.map { it.invoice })
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLi
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
|
||||
@@ -145,6 +146,7 @@ class RelaySubscriptionsCoordinator(
|
||||
val podcastEpisodes = PodcastEpisodesFilterAssembler(client)
|
||||
val podcasts = PodcastsFilterAssembler(client)
|
||||
val onePodcast = OnePodcastFilterAssembler(client)
|
||||
val myPodcast = MyPodcastFilterAssembler(client)
|
||||
val softwareApps = SoftwareAppsFilterAssembler(client)
|
||||
val napplets = NappletsFilterAssembler(client)
|
||||
val connectedApps = ConnectedAppsFilterAssembler(client)
|
||||
@@ -198,6 +200,7 @@ class RelaySubscriptionsCoordinator(
|
||||
podcastEpisodes,
|
||||
podcasts,
|
||||
onePodcast,
|
||||
myPodcast,
|
||||
softwareApps,
|
||||
badges,
|
||||
profileBadges,
|
||||
|
||||
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.BookmarkedPodcastsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
|
||||
@@ -179,6 +180,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScre
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastEpisodeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
|
||||
@@ -373,6 +378,10 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.PodcastEpisodes> { PodcastEpisodesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
|
||||
composableFromEnd<Route.PodcastAuthoring> { PodcastAuthoringScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditPodcastShow> { EditPodcastShowScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NewPodcastEpisode> { NewPodcastEpisodeScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
|
||||
composableFromEnd<Route.NewPodcastTrailer> { NewPodcastTrailerScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
|
||||
composableFromEndArgs<Route.NewMusicPlaylist> { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
|
||||
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
|
||||
@@ -446,6 +455,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BookmarkedRepositories> { BookmarkedRepositoriesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BookmarkedPodcasts> { BookmarkedPodcastsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -196,6 +196,17 @@ sealed class Route {
|
||||
val pubkey: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable object PodcastAuthoring : Route()
|
||||
|
||||
@Serializable object EditPodcastShow : Route()
|
||||
|
||||
@Serializable
|
||||
data class NewPodcastEpisode(
|
||||
val dTag: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable object NewPodcastTrailer : Route()
|
||||
|
||||
@Serializable
|
||||
data class NewMusicTrack(
|
||||
val dTag: String? = null,
|
||||
@@ -304,6 +315,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object BookmarkedRepositories : Route()
|
||||
|
||||
@Serializable object BookmarkedPodcasts : Route()
|
||||
|
||||
@Serializable object BookmarkGroups : Route()
|
||||
|
||||
@Serializable object InterestSets : Route()
|
||||
|
||||
+2
-1
@@ -44,13 +44,14 @@ import com.vitorpamplona.amethyst.ui.theme.isLight
|
||||
fun TopBarExtensibleWithBackButton(
|
||||
title: @Composable RowScope.() -> Unit,
|
||||
extendableRow: (@Composable () -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
popBack: () -> Unit,
|
||||
) {
|
||||
MyExtensibleTopAppBar(
|
||||
title = title,
|
||||
extendableRow = extendableRow,
|
||||
navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } },
|
||||
actions = {},
|
||||
actions = actions,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -325,6 +326,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
|
||||
@@ -344,6 +346,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -982,10 +987,39 @@ private fun RenderNoteRow(
|
||||
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is Podcasting20EpisodeEvent -> {
|
||||
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is Podcasting20TrailerEvent -> {
|
||||
PodcastTrailerListItem(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is PodcastMetadataEvent -> {
|
||||
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is AppSpecificDataEvent -> {
|
||||
// kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a
|
||||
// podcast card. Anything else (e.g. a client's own settings) keeps the text fallback.
|
||||
if (noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) {
|
||||
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
|
||||
} else {
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
makeItShort,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
unPackReply,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
isBoostedNote = isBoostedNote,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DraftWrapEvent -> {
|
||||
RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* A bookmark toggle for a podcast (show or episode). "Favoriting"/subscribing to a podcast reuses
|
||||
* the existing NIP-51 bookmark list (kind 10003) — a public bookmark matches the soft public
|
||||
* recommendation the dedicated favorites list (10054) was meant for, and the note then appears in
|
||||
* the standard Bookmarks screen. Works for both regular events (e-tag) and addressable shows/
|
||||
* episodes (a-tag) because [AccountViewModel.addPublicBookmark] branches on the note type.
|
||||
*
|
||||
* Bookmarked state is read from the public bookmark id/address **sets** (not `List<Note>`
|
||||
* containment) so it reflects reliably for addressable notes and updates the moment the list
|
||||
* changes — the icon flipping filled/outline is the feedback, no toast needed.
|
||||
*
|
||||
* Rendered as a compact clickable glyph (not a Material [androidx.compose.material3.IconButton],
|
||||
* whose 48dp minimum touch target would stand taller than the title it sits beside). [iconSize]
|
||||
* defaults to a single title line so it aligns when placed next to a show/episode title.
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastBookmarkButton(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
iconSize: Dp = 20.dp,
|
||||
) {
|
||||
val bookmarkState = accountViewModel.account.bookmarkState
|
||||
|
||||
val publicAddresses by bookmarkState.publicBookmarkAddressIdSet.collectAsStateWithLifecycle()
|
||||
val publicEvents by bookmarkState.publicBookmarkEventIdSet.collectAsStateWithLifecycle()
|
||||
|
||||
val isBookmarked =
|
||||
remember(note, publicAddresses, publicEvents) {
|
||||
if (note is AddressableNote) {
|
||||
note.address in publicAddresses
|
||||
} else {
|
||||
note.idHex in publicEvents
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
onClick = {
|
||||
if (isBookmarked) {
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
} else {
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
}
|
||||
},
|
||||
).padding(4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (isBookmarked) R.string.remove_from_public_bookmarks else R.string.add_to_public_bookmarks,
|
||||
),
|
||||
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(iconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastChapter
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastChapters
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
|
||||
private sealed interface ChaptersUiState {
|
||||
data object Loading : ChaptersUiState
|
||||
|
||||
data class Loaded(
|
||||
val chapters: List<PodcastChapter>,
|
||||
) : ChaptersUiState
|
||||
|
||||
data object Failed : ChaptersUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the episode's off-event Podcasting-2.0 chapters document on first composition and renders
|
||||
* it as a tinted list of `timestamp — title` rows. Fetch is lazy (callers gate it behind an expand
|
||||
* toggle) so scrolling a feed never triggers network. On failure or empty, renders nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastChaptersSection(
|
||||
chaptersUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state by produceState<ChaptersUiState>(ChaptersUiState.Loading, chaptersUrl) {
|
||||
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl)
|
||||
val parsed = loadChapters(chaptersUrl, client)
|
||||
value = if (parsed != null) ChaptersUiState.Loaded(parsed.chapters) else ChaptersUiState.Failed
|
||||
}
|
||||
|
||||
when (val current = state) {
|
||||
is ChaptersUiState.Loading ->
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
|
||||
is ChaptersUiState.Failed -> {}
|
||||
|
||||
is ChaptersUiState.Loaded -> {
|
||||
val chapters = current.chapters
|
||||
if (chapters.isEmpty()) return
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
chapters.forEach { chapter ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = formatTimestamp(chapter.startSeconds()),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.widthIn(min = 44.dp),
|
||||
)
|
||||
Text(
|
||||
text = chapter.title ?: stringRes(R.string.podcast_chapters),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadChapters(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): PodcastChapters? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder().url(url).build()
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
if (response.isSuccessful) PodcastChapters.parse(response.body.string()) else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("PodcastChapters", "Failed to load chapters from $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(seconds: Long): String {
|
||||
val hours = seconds / 3600
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val secs = seconds % 60
|
||||
return if (hours > 0) {
|
||||
"%d:%02d:%02d".format(hours, minutes, secs)
|
||||
} else {
|
||||
"%d:%02d".format(minutes, secs)
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastChapter
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastChapters
|
||||
|
||||
/**
|
||||
* The episode's Podcasting-2.0 chapters (from the off-event `chapters.json` referenced by the
|
||||
* `chapters` tag), fetched and rendered as a collapsible, tappable list. Tapping a chapter calls
|
||||
* [onSeek] with its start in milliseconds so the host can seek the live media controller — the same
|
||||
* contract as [PodcastSoundbites].
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastChaptersView(
|
||||
chaptersUrl: String,
|
||||
onSeek: (startMillis: Long) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val chapters by produceState(initialValue = emptyList<PodcastChapter>(), chaptersUrl) {
|
||||
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl)
|
||||
val body = PodcastRemoteContent.fetchText(chaptersUrl, client)
|
||||
value = body?.let { PodcastChapters.parse(it)?.chapters }?.filter { it.title?.isNotBlank() == true } ?: emptyList()
|
||||
}
|
||||
|
||||
if (chapters.isEmpty()) return
|
||||
|
||||
var expanded by remember(chaptersUrl) { mutableStateOf(false) }
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
CollapsibleHeader(
|
||||
symbol = MaterialSymbols.AutoMirrored.FormatListBulleted,
|
||||
title = pluralStringResource(R.plurals.podcast_chapters_count, chapters.size, chapters.size),
|
||||
expanded = expanded,
|
||||
onToggle = { expanded = !expanded },
|
||||
)
|
||||
|
||||
if (expanded) {
|
||||
chapters.forEach { chapter ->
|
||||
ChapterRow(chapter, onSeek)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChapterRow(
|
||||
chapter: PodcastChapter,
|
||||
onSeek: (Long) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSeek(chapter.startSeconds() * 1000) }
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = formatChapterTime(chapter.startTime),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = chapter.title.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CollapsibleHeader(
|
||||
symbol: MaterialSymbol,
|
||||
title: String,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Size18Modifier,
|
||||
tint = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatChapterTime(totalSeconds: Double): String {
|
||||
val total = totalSeconds.toLong()
|
||||
val h = total / 3600
|
||||
val m = (total % 3600) / 60
|
||||
val s = total % 60
|
||||
val two = { n: Long -> if (n < 10) "0$n" else "$n" }
|
||||
return if (h > 0) "$h:${two(m)}:${two(s)}" else "$m:${two(s)}"
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
|
||||
/**
|
||||
* A small rounded, tinted pill for a podcast attribute (explicit, completed, a genre, a season/
|
||||
* episode number, …). Shared by the show and episode renderers so they read as one design.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PodcastBadge(
|
||||
label: String,
|
||||
symbol: MaterialSymbol?,
|
||||
container: Color,
|
||||
content: Color,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(container)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
symbol?.let {
|
||||
Icon(
|
||||
symbol = it,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = content,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A clickable pill that opens an external resource (a website, a transcript, chapters, …). */
|
||||
@Composable
|
||||
internal fun PodcastLinkChip(
|
||||
label: String,
|
||||
symbol: MaterialSymbol,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
+105
-18
@@ -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() }
|
||||
|
||||
+59
-16
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+108
-21
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -37,6 +38,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -65,14 +67,23 @@ fun RenderPodcastMetadata(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? PodcastMetadataEvent ?: return
|
||||
val noteEvent = note.event ?: return
|
||||
// Resolves NIP-F4 kind:10154 and Podcasting-2.0 kind:30078 shows to one PodcastShow view.
|
||||
val show = remember(noteEvent) { resolvePodcastShow(noteEvent) } ?: return
|
||||
|
||||
val title = remember(noteEvent) { noteEvent.title() }
|
||||
val image = remember(noteEvent) { noteEvent.image() }
|
||||
val description = remember(noteEvent) { noteEvent.description() }
|
||||
val websites = remember(noteEvent) { noteEvent.websites() }
|
||||
// Each podcast is its own keypair, so the author pubkey IS the podcast id used to open
|
||||
// its dedicated screen with the full episode list.
|
||||
val title = remember(noteEvent) { show.showTitle() }
|
||||
val author = remember(noteEvent) { show.showAuthor() }
|
||||
val image = remember(noteEvent) { show.showImage() }
|
||||
val description = remember(noteEvent) { show.showDescription() }
|
||||
val websites = remember(noteEvent) { show.showWebsites() }
|
||||
val categories = remember(noteEvent) { show.showCategories() }
|
||||
val fundingUrls = remember(noteEvent) { show.showFundingUrls() }
|
||||
val isExplicit = remember(noteEvent) { show.showIsExplicit() }
|
||||
val isComplete = remember(noteEvent) { show.showIsComplete() }
|
||||
val copyright = remember(noteEvent) { show.showCopyright() }
|
||||
val value = remember(noteEvent) { show.showValue() }
|
||||
// In both drafts the show's author pubkey IS the podcast id used to open its dedicated
|
||||
// screen with the full episode list (episodes are authored by the same key).
|
||||
val podcastPubkey = remember(noteEvent) { noteEvent.pubKey }
|
||||
|
||||
Column(
|
||||
@@ -89,17 +100,65 @@ fun RenderPodcastMetadata(
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
title?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
PodcastBookmarkButton(note, accountViewModel)
|
||||
}
|
||||
|
||||
author?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
text = stringRes(R.string.podcast_by_author, it),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (isExplicit || isComplete || categories.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (isComplete) {
|
||||
PodcastBadge(
|
||||
label = stringRes(R.string.podcast_completed),
|
||||
symbol = MaterialSymbols.CheckCircle,
|
||||
container = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
content = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
if (isExplicit) {
|
||||
PodcastBadge(
|
||||
label = stringRes(R.string.podcast_explicit),
|
||||
symbol = null,
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
categories.forEach { category ->
|
||||
PodcastBadge(
|
||||
label = category,
|
||||
symbol = MaterialSymbols.Tag,
|
||||
container = MaterialTheme.colorScheme.secondaryContainer,
|
||||
content = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
description?.takeIf { !makeItShort }?.let {
|
||||
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
|
||||
|
||||
@@ -116,23 +175,51 @@ fun RenderPodcastMetadata(
|
||||
)
|
||||
}
|
||||
|
||||
value?.takeIf { !makeItShort }?.let {
|
||||
PodcastValueSplits(value = it)
|
||||
}
|
||||
|
||||
if (fundingUrls.isNotEmpty() && !makeItShort) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Button(
|
||||
onClick = { runCatching { uriHandler.openUri(fundingUrls.first()) } },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_support_show),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (websites.isNotEmpty() && !makeItShort) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
websites.forEach { website ->
|
||||
Text(
|
||||
text = website,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
PodcastLinkChip(website, MaterialSymbols.Public) { runCatching { uriHandler.openUri(website) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyright?.takeIf { !makeItShort }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
modifier = Modifier.padding(top = Size5dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Affordance that this card opens a full show page with every episode.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastPerson
|
||||
|
||||
/**
|
||||
* A "Hosts & Guests" strip: the Podcasting-2.0 `podcast:person` credits for a show or episode,
|
||||
* rendered as a horizontally scrollable row of avatar + name + role.
|
||||
*
|
||||
* A person is usually a free-text credit (name + image URL + web link), not a Nostr user, so it's
|
||||
* drawn with the app's default profile-image loader and its link opens externally. But when the
|
||||
* publisher's `href` points at an `npub`/`nprofile`, we upgrade the card to a real Nostr profile —
|
||||
* the standard [ClickableUserPicture] + [UsernameDisplay], tappable through to the profile.
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastPeople(
|
||||
persons: List<PodcastPerson>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val people = persons.filter { it.isValid() }
|
||||
if (people.isEmpty()) return
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_hosts_and_guests),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(people) { person ->
|
||||
PersonItem(person, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonItem(
|
||||
person: PodcastPerson,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pubKey = remember(person) { person.nostrPubKey() }
|
||||
|
||||
if (pubKey != null) {
|
||||
LoadUser(pubKey, accountViewModel) { user ->
|
||||
if (user != null) {
|
||||
NostrPersonCard(user, person.role, accountViewModel, nav)
|
||||
} else {
|
||||
FreeTextPersonCard(person, accountViewModel)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FreeTextPersonCard(person, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/** A person that resolved to a real Nostr identity — the standard profile treatment. */
|
||||
@Composable
|
||||
private fun NostrPersonCard(
|
||||
user: User,
|
||||
role: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
PersonCardScaffold(
|
||||
onClick = { nav.nav(routeFor(user)) },
|
||||
role = role,
|
||||
avatar = { ClickableUserPicture(user, 56.dp, accountViewModel) },
|
||||
name = {
|
||||
UsernameDisplay(
|
||||
user,
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A free-text `podcast:person` credit — default image loader, external link. */
|
||||
@Composable
|
||||
private fun FreeTextPersonCard(
|
||||
person: PodcastPerson,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val href = person.href
|
||||
|
||||
PersonCardScaffold(
|
||||
onClick = href?.let { { runCatching { uriHandler.openUri(it) } } },
|
||||
role = person.role,
|
||||
avatar = {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = person.name,
|
||||
model = person.img,
|
||||
contentDescription = person.name,
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
},
|
||||
name = {
|
||||
Text(
|
||||
text = person.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared 72dp centered card layout: avatar, name, and an optional role line. */
|
||||
@Composable
|
||||
private fun PersonCardScaffold(
|
||||
onClick: (() -> Unit)?,
|
||||
role: String?,
|
||||
avatar: @Composable () -> Unit,
|
||||
name: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(72.dp)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
avatar()
|
||||
name()
|
||||
role?.takeIf { it.isNotEmpty() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
|
||||
|
||||
/**
|
||||
* The episode's Podcasting-2.0 `podcast:soundbite` highlight clips as "jump to the good part" chips.
|
||||
* Tapping one calls [onPlayFrom] with the clip's start offset in milliseconds; the host wires that
|
||||
* to the media controller so playback seeks there. Kept controller-agnostic so it can live wherever
|
||||
* a seek callback is available.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PodcastSoundbites(
|
||||
soundbites: List<PodcastSoundbite>,
|
||||
onPlayFrom: (startMillis: Long) -> Unit,
|
||||
) {
|
||||
if (soundbites.isEmpty()) return
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
soundbites.forEach { soundbite ->
|
||||
val label = soundbite.title?.takeIf { it.isNotBlank() } ?: formatClock(soundbite.startTimeSeconds)
|
||||
AssistChip(
|
||||
onClick = { onPlayFrom(soundbite.startMillis()) },
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PlayArrow,
|
||||
contentDescription = stringRes(R.string.podcast_play_soundbite),
|
||||
modifier = Modifier.size(AssistChipDefaults.IconSize),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatClock(totalSeconds: Double): String {
|
||||
val total = totalSeconds.toLong()
|
||||
val minutes = total / 60
|
||||
val seconds = total % 60
|
||||
val secStr = if (seconds < 10) "0$seconds" else "$seconds"
|
||||
return "$minutes:$secStr"
|
||||
}
|
||||
+225
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+111
@@ -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() }
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
|
||||
/**
|
||||
* Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header, a
|
||||
* one-line hint that zaps to this item are split, and one row per recipient (name/address + its
|
||||
* share of the split).
|
||||
*
|
||||
* This is a **breakdown display only** — there is no dedicated send button. Paying the split is the
|
||||
* job of the standard zap button: when a podcast note carries a value block, [AccountViewModel.zap]
|
||||
* detects it and fans the chosen amount out to these recipients (lnaddress shares as real zaps, node
|
||||
* shares as keysend). Keeping a separate "Send value" button here would just duplicate that action.
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastValueSplits(value: PodcastValue) {
|
||||
val recipients = value.recipients.filter { it.split > 0 || it.address != null }
|
||||
if (recipients.isEmpty()) return
|
||||
|
||||
val total = value.totalSplit().takeIf { it > 0 } ?: recipients.size
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_value_for_value),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_value_zap_split_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
recipients.forEach { recipient ->
|
||||
val label = recipient.name?.takeIf { it.isNotEmpty() } ?: recipient.address.orEmpty()
|
||||
val percent = recipient.split * 100 / total
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
recipient.address
|
||||
?.takeIf { it.isNotEmpty() && it != label }
|
||||
?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_value_split_percent, percent),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,24 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
private val _podcastRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
liveInterestFlows,
|
||||
) { peopleLists, interests ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
// Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the
|
||||
// podcasts + episodes screens can show only the user's own published shows/episodes.
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
|
||||
peopleLists,
|
||||
interests,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val _kind3GlobalPeople =
|
||||
livePeopleListsFlow.transform { peopleLists ->
|
||||
checkNotInMainThread()
|
||||
@@ -408,6 +426,11 @@ class TopNavFilterState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val podcastRoutes =
|
||||
_podcastRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
fun destroy() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
+113
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
@@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
@@ -158,6 +160,10 @@ import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imeta
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastShow
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -938,6 +944,27 @@ class AccountViewModel(
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType? = null,
|
||||
) = launchSigner {
|
||||
// A podcast note (episode or show) can carry a Podcasting-2.0 value-for-value block. When it
|
||||
// does, "zapping" it means paying that split — lnaddress recipients go out as real zaps (with
|
||||
// receipts, which drive this same button's icon/counter), node recipients go out as keysend.
|
||||
// This makes the standard zap button the single payment action for V4V content.
|
||||
val v4v =
|
||||
(note.event as? PodcastEpisode)?.episodeValue()
|
||||
?: (note.event as? PodcastShow)?.showValue()
|
||||
if (v4v != null && v4v.recipients.any { it.split > 0 && !it.address.isNullOrBlank() }) {
|
||||
executeV4V(
|
||||
value = v4v,
|
||||
totalMilliSats = amountInMillisats,
|
||||
podcastName = (note.event as? PodcastShow)?.showTitle(),
|
||||
episodeName = (note.event as? PodcastEpisode)?.episodeTitle(),
|
||||
zappedNote = note,
|
||||
context = context,
|
||||
streaming = false,
|
||||
onProgress = onProgress,
|
||||
)
|
||||
return@launchSigner
|
||||
}
|
||||
|
||||
val requestedType = zapType ?: defaultZapType()
|
||||
|
||||
// Zaps on private rumors are forced to PRIVATE so the sender and
|
||||
@@ -965,6 +992,92 @@ class AccountViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in
|
||||
* the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via
|
||||
* NWC keysend with the boostagram TLV).
|
||||
*
|
||||
* [streaming] marks this as a per-minute streaming payment rather than a one-off boost: the
|
||||
* boostagram action becomes "stream" and errors are swallowed instead of toasted — a streaming
|
||||
* session fires once a minute and we don't want per-minute toast spam. One-off boosts surface
|
||||
* errors on [toastManager]. The external-wallet intent fallback is skipped while [streaming]
|
||||
* (you can't auto-fire a wallet app every minute); streaming is gated to NWC/CLINK callers.
|
||||
*/
|
||||
fun payV4V(
|
||||
value: PodcastValue,
|
||||
totalSats: Long,
|
||||
podcastName: String?,
|
||||
episodeName: String?,
|
||||
zappedNote: Note?,
|
||||
context: Context,
|
||||
streaming: Boolean = false,
|
||||
onProgress: (Float) -> Unit = {},
|
||||
) = launchSigner {
|
||||
executeV4V(
|
||||
value = value,
|
||||
totalMilliSats = totalSats * 1000,
|
||||
podcastName = podcastName,
|
||||
episodeName = episodeName,
|
||||
zappedNote = zappedNote,
|
||||
context = context,
|
||||
streaming = streaming,
|
||||
onProgress = onProgress,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared V4V execution used by both [payV4V] and the V4V reroute inside [zap]. Must be called
|
||||
* from within a [launchSigner] block (it does signing). [streaming] = true marks per-minute
|
||||
* payments: errors are swallowed (no per-minute toast spam), the external-wallet intent fallback
|
||||
* is skipped (can't auto-launch a wallet every minute), and lnaddress shares are paid WITHOUT a
|
||||
* zap request so streaming doesn't publish a receipt every minute. One-off boosts ([streaming] =
|
||||
* false) pay lnaddress shares as real zaps, producing receipts that feed the zap button's UI.
|
||||
*/
|
||||
private suspend fun executeV4V(
|
||||
value: PodcastValue,
|
||||
totalMilliSats: Long,
|
||||
podcastName: String?,
|
||||
episodeName: String?,
|
||||
zappedNote: Note?,
|
||||
context: Context,
|
||||
streaming: Boolean,
|
||||
onProgress: (Float) -> Unit,
|
||||
) {
|
||||
val boostagram =
|
||||
PodcastBoostagram(
|
||||
podcast = podcastName,
|
||||
episode = episodeName,
|
||||
action = if (streaming) PodcastBoostagram.ACTION_STREAM else PodcastBoostagram.ACTION_BOOST,
|
||||
appName = "Amethyst",
|
||||
valueMsatTotal = totalMilliSats,
|
||||
senderName = account.userProfile().toBestDisplayName(),
|
||||
)
|
||||
|
||||
V4VPaymentHandler(account).pay(
|
||||
value = value,
|
||||
totalMilliSats = totalMilliSats,
|
||||
boostagram = boostagram,
|
||||
zappedNote = zappedNote,
|
||||
context = context,
|
||||
asZap = !streaming,
|
||||
zapType = LnZapEvent.ZapType.PUBLIC,
|
||||
okHttpClient = httpClientBuilder::okHttpClientForMoney,
|
||||
onError = { title, message ->
|
||||
if (!streaming) toastManager.toast(title, message)
|
||||
},
|
||||
onProgress = onProgress,
|
||||
onPayInvoicesViaIntent = { invoices ->
|
||||
if (!streaming) {
|
||||
invoices.forEach { invoice ->
|
||||
payViaIntent(invoice, context, onPaid = {}, onError = {
|
||||
toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget NIP-61 nutzap from the zap picker. Picks a mint the
|
||||
* recipient accepts (via their kind:10019) that we also have proofs at,
|
||||
|
||||
+55
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent
|
||||
|
||||
/**
|
||||
* The podcast subset of the user's NIP-51 bookmark list (kind 10003): podcasts (shows and episodes)
|
||||
* are bookmarked into the same general list as everything else, so this filter pulls just the
|
||||
* podcast-typed notes ([isPodcastEvent]) back out — both public and private — newest first.
|
||||
*/
|
||||
class BookmarkPodcastsFeedFilter(
|
||||
val account: Account,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String =
|
||||
account.bookmarkState.bookmarks.value
|
||||
.hashCode()
|
||||
.toString()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val bookmarks = account.bookmarkState.bookmarks.value
|
||||
return (bookmarks.public + bookmarks.private)
|
||||
.filter { isPodcastEvent(it.event) }
|
||||
.distinct()
|
||||
.sortedByDescending { it.createdAt() ?: 0L }
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
|
||||
|
||||
@Stable
|
||||
class BookmarkPodcastsFeedViewModel(
|
||||
val account: Account,
|
||||
) : AndroidFeedViewModel(BookmarkPodcastsFeedFilter(account)) {
|
||||
class Factory(
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = BookmarkPodcastsFeedViewModel(account) as T
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
|
||||
|
||||
/**
|
||||
* Renders a NIP-F4 podcast's claimed authors (`kind:10154` `p` tags, each a pubkey + role). The
|
||||
* claims are unverified — the show can name anyone — so each author is cross-checked against their
|
||||
* own counter-claim ([AuthoredPodcastsEvent], `kind:10064`): a verified check appears only when that
|
||||
* author's 10064 actually lists this podcast's pubkey. The 10064 is fetched + observed lazily via
|
||||
* [observeNoteEvent], so it arrives and flips the badge without extra wiring.
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastAuthors(
|
||||
podcastPubkey: HexKey,
|
||||
authors: List<AuthorTag>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
authors.forEach { author ->
|
||||
PodcastAuthorRow(podcastPubkey, author, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodcastAuthorRow(
|
||||
podcastPubkey: HexKey,
|
||||
author: AuthorTag,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var user by remember(author.pubKey) { mutableStateOf(accountViewModel.getUserIfExists(author.pubKey)) }
|
||||
if (user == null) {
|
||||
LaunchedEffect(author.pubKey) {
|
||||
user = accountViewModel.checkGetOrCreateUser(author.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
val authoredNote =
|
||||
remember(author.pubKey) {
|
||||
LocalCache.getOrCreateAddressableNote(AuthoredPodcastsEvent.createAddress(author.pubKey))
|
||||
}
|
||||
val authored by observeNoteEvent<AuthoredPodcastsEvent>(authoredNote, accountViewModel)
|
||||
val verified = authored?.authors(podcastPubkey) == true
|
||||
|
||||
val loadedUser = user ?: return
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav(routeFor(loadedUser)) }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClickableUserPicture(loadedUser, 28.dp, accountViewModel)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
UsernameDisplay(loadedUser, accountViewModel = accountViewModel)
|
||||
author.role?.let {
|
||||
Text(
|
||||
text = roleLabel(it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (verified) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CheckCircle,
|
||||
contentDescription = stringRes(R.string.podcast_author_verified),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun roleLabel(role: String): String =
|
||||
when (role) {
|
||||
AuthorTag.ROLE_HOST -> stringRes(R.string.podcast_role_host)
|
||||
AuthorTag.ROLE_COHOST -> stringRes(R.string.podcast_role_cohost)
|
||||
AuthorTag.ROLE_EDITOR -> stringRes(R.string.podcast_role_editor)
|
||||
else -> role
|
||||
}
|
||||
+35
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+43
-6
@@ -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,
|
||||
|
||||
+60
-20
@@ -21,8 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -53,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
|
||||
import com.vitorpamplona.amethyst.ui.note.types.PodcastBookmarkButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel
|
||||
@@ -60,8 +64,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastShow
|
||||
|
||||
@Composable
|
||||
fun PodcastScreen(
|
||||
@@ -70,10 +81,18 @@ fun PodcastScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return
|
||||
val metadataNote =
|
||||
// A show is either NIP-F4 (kind 10154) or Podcasting-2.0 (kind 30078, d=podcast-metadata).
|
||||
// Resolve both addresses; whichever has an event is this podcast's metadata.
|
||||
val f4Note =
|
||||
remember(pubkey) {
|
||||
LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey))
|
||||
}
|
||||
val p20Note =
|
||||
remember(pubkey) {
|
||||
LocalCache.getOrCreateAddressableNote(
|
||||
Address(AppSpecificDataEvent.KIND, pubkey, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG),
|
||||
)
|
||||
}
|
||||
|
||||
val feedViewModel: OnePodcastFeedViewModel =
|
||||
viewModel(
|
||||
@@ -81,23 +100,27 @@ fun PodcastScreen(
|
||||
factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account),
|
||||
)
|
||||
|
||||
PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav)
|
||||
PodcastScreen(podcast, f4Note, p20Note, feedViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PodcastScreen(
|
||||
podcast: User,
|
||||
metadataNote: Note,
|
||||
f4Note: Note,
|
||||
p20Note: Note,
|
||||
feedViewModel: OnePodcastFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(feedViewModel)
|
||||
// Fetches both the show metadata (kind 10154) and every episode (kind 54) authored by
|
||||
// this podcast's key from its outbox relays.
|
||||
// Fetches the show metadata (NIP-F4 kind 10154 / Podcasting-2.0 kind 30078) and every episode
|
||||
// (kind 54 / kind 30054) + trailer authored by this podcast's key from its outbox relays.
|
||||
OnePodcastFilterAssemblerSubscription(podcast, accountViewModel)
|
||||
|
||||
val metadataEvent by observeNoteEvent<PodcastMetadataEvent>(metadataNote, accountViewModel)
|
||||
val f4Event by observeNoteEvent<PodcastMetadataEvent>(f4Note, accountViewModel)
|
||||
val p20Event by observeNoteEvent<AppSpecificDataEvent>(p20Note, accountViewModel)
|
||||
val show: PodcastShow? = remember(f4Event, p20Event) { resolvePodcastShow(f4Event) ?: resolvePodcastShow(p20Event) }
|
||||
val metadataNote = if (f4Event != null) f4Note else p20Note
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
@@ -105,12 +128,23 @@ fun PodcastScreen(
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
Text(
|
||||
text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts),
|
||||
text = show?.showTitle() ?: stringRes(R.string.route_podcasts),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// Only offer these once the show metadata has loaded — there must be a resolved
|
||||
// event to bookmark / act on. The bookmark button reflects bookmarked state; the
|
||||
// 3-dot button is the standard NoteCompose options menu, sitting to its right.
|
||||
if (show != null) {
|
||||
PodcastBookmarkButton(metadataNote, accountViewModel)
|
||||
Spacer(Modifier.width(Size10dp))
|
||||
MoreOptionsButton(metadataNote, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(Modifier.width(Size10dp))
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
@@ -120,7 +154,7 @@ fun PodcastScreen(
|
||||
SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState ->
|
||||
PodcastScreenBody(
|
||||
metadataNote = metadataNote,
|
||||
metadataEvent = metadataEvent,
|
||||
show = show,
|
||||
feedViewModel = feedViewModel,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -134,7 +168,7 @@ fun PodcastScreen(
|
||||
@Composable
|
||||
private fun PodcastScreenBody(
|
||||
metadataNote: Note,
|
||||
metadataEvent: PodcastMetadataEvent?,
|
||||
show: PodcastShow?,
|
||||
feedViewModel: OnePodcastFeedViewModel,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -144,20 +178,20 @@ private fun PodcastScreenBody(
|
||||
|
||||
when (val state = feedState) {
|
||||
is FeedState.Loaded ->
|
||||
PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav)
|
||||
PodcastEpisodesList(metadataNote, show, state, listState, accountViewModel, nav)
|
||||
|
||||
is FeedState.Empty ->
|
||||
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
|
||||
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
|
||||
StatusText(stringRes(R.string.podcast_no_episodes))
|
||||
}
|
||||
|
||||
is FeedState.FeedError ->
|
||||
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
|
||||
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
|
||||
FeedError(state.errorMessage) { feedViewModel.invalidateData() }
|
||||
}
|
||||
|
||||
is FeedState.Loading ->
|
||||
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
|
||||
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
|
||||
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
@@ -168,7 +202,7 @@ private fun PodcastScreenBody(
|
||||
@Composable
|
||||
private fun PodcastEpisodesList(
|
||||
metadataNote: Note,
|
||||
metadataEvent: PodcastMetadataEvent?,
|
||||
show: PodcastShow?,
|
||||
loaded: FeedState.Loaded,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -181,15 +215,21 @@ private fun PodcastEpisodesList(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
) {
|
||||
item("header") {
|
||||
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav)
|
||||
// The list mixes in trailers; the header count should reflect episodes only.
|
||||
val episodeCount = items.list.count { it.event !is Podcasting20TrailerEvent }
|
||||
PodcastHeader(metadataNote, show, episodeCount, accountViewModel, nav)
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, _ -> "episode" },
|
||||
) { index, episode ->
|
||||
PodcastEpisodeListItem(episode, accountViewModel, nav)
|
||||
contentType = { _, item -> if (item.event is Podcasting20TrailerEvent) "trailer" else "episode" },
|
||||
) { index, item ->
|
||||
if (item.event is Podcasting20TrailerEvent) {
|
||||
PodcastTrailerListItem(item, accountViewModel, nav)
|
||||
} else {
|
||||
PodcastEpisodeListItem(item, accountViewModel, nav)
|
||||
}
|
||||
|
||||
if (index < items.list.lastIndex) {
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
@@ -201,7 +241,7 @@ private fun PodcastEpisodesList(
|
||||
@Composable
|
||||
private fun PodcastHeaderWithStatus(
|
||||
metadataNote: Note,
|
||||
metadataEvent: PodcastMetadataEvent?,
|
||||
show: PodcastShow?,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
@@ -212,7 +252,7 @@ private fun PodcastHeaderWithStatus(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
) {
|
||||
item("header") {
|
||||
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav)
|
||||
PodcastHeader(metadataNote, show, null, accountViewModel, nav)
|
||||
}
|
||||
item("status") { status() }
|
||||
}
|
||||
|
||||
+188
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Editor for the creator's Podcasting-2.0 show metadata (`kind:30078`, `d="podcast-metadata"`).
|
||||
* There is one show per account, so this is always create-or-edit of the same event. Cover upload
|
||||
* plus the channel fields; explicit / complete / locked as switches; episodic vs serial as a toggle.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditPodcastShowScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val vm: EditPodcastShowViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
|
||||
|
||||
StrippingFailureDialog(vm.strippingFailureConfirmation)
|
||||
|
||||
var wantsToPickCover by remember { mutableStateOf(false) }
|
||||
if (wantsToPickCover) {
|
||||
GallerySelectSingle(
|
||||
onImageUri = { picked ->
|
||||
wantsToPickCover = false
|
||||
vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isBusy = vm.isSending.value
|
||||
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SendingTopBar(
|
||||
titleRes = R.string.podcast_edit_show,
|
||||
onCancel = { nav.popBack() },
|
||||
isActive = { vm.isValid() && !isBusy },
|
||||
onPost = {
|
||||
if (!vm.isValid() || isBusy) return@SendingTopBar
|
||||
vm.saveAndPublish(context, accountViewModel)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
|
||||
|
||||
CoverImagePicker(
|
||||
cover = vm.coverMedia.value,
|
||||
existingUrl = vm.coverUrl.value,
|
||||
onPick = { wantsToPickCover = true },
|
||||
onDelete = { vm.clearPickedCover() },
|
||||
accountViewModel = accountViewModel,
|
||||
enabled = !isBusy,
|
||||
ctaRes = R.string.podcast_show_cover_cta,
|
||||
hintRes = R.string.podcast_show_cover_hint,
|
||||
)
|
||||
|
||||
Field(vm.title, R.string.podcast_show_title_label, R.string.podcast_show_title_placeholder, isError = vm.title.value.isBlank())
|
||||
|
||||
OutlinedTextField(
|
||||
value = vm.description.value,
|
||||
onValueChange = { vm.description.value = it },
|
||||
label = { Text(stringRes(R.string.podcast_show_description_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 4,
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||
)
|
||||
|
||||
Field(vm.author, R.string.podcast_show_author_label, null, capitalization = KeyboardCapitalization.Words)
|
||||
Field(vm.email, R.string.podcast_show_email_label, null, keyboardType = KeyboardType.Email)
|
||||
Field(vm.website, R.string.podcast_show_website_label, null, keyboardType = KeyboardType.Uri)
|
||||
Field(vm.categories, R.string.podcast_show_categories_label, R.string.podcast_show_categories_placeholder)
|
||||
Field(vm.funding, R.string.podcast_show_funding_label, R.string.podcast_show_funding_placeholder, keyboardType = KeyboardType.Uri)
|
||||
Field(vm.language, R.string.podcast_show_language_label, R.string.podcast_show_language_placeholder)
|
||||
Field(vm.copyright, R.string.podcast_show_copyright_label, null)
|
||||
|
||||
// Episodic vs serial.
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_show_type_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = vm.type.value == "episodic" || vm.type.value.isBlank(),
|
||||
onClick = { vm.type.value = "episodic" },
|
||||
label = { Text(stringRes(R.string.podcast_show_type_episodic)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = vm.type.value == "serial",
|
||||
onClick = { vm.type.value = "serial" },
|
||||
label = { Text(stringRes(R.string.podcast_show_type_serial)) },
|
||||
)
|
||||
}
|
||||
|
||||
SwitchRow(stringRes(R.string.podcast_show_explicit), vm.explicit.value) { vm.explicit.value = it }
|
||||
SwitchRow(stringRes(R.string.podcast_show_complete), vm.complete.value) { vm.complete.value = it }
|
||||
SwitchRow(stringRes(R.string.podcast_show_locked), vm.locked.value) { vm.locked.value = it }
|
||||
|
||||
V4VSplitEditor(vm.splitEditor, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Field(
|
||||
state: androidx.compose.runtime.MutableState<String>,
|
||||
labelRes: Int,
|
||||
placeholderRes: Int?,
|
||||
isError: Boolean = false,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.value,
|
||||
onValueChange = { state.value = it },
|
||||
label = { Text(stringRes(labelRes)) },
|
||||
placeholder = placeholderRes?.let { { Text(stringRes(it)) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Switch(checked = checked, onCheckedChange = onChange)
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Editor for a creator's Podcasting-2.0 show metadata — the single replaceable `kind:30078`
|
||||
* (`d="podcast-metadata"`) event whose JSON body holds the channel-level fields. There is one per
|
||||
* account, so this is always an edit-or-create of the same address; saving replaces it in place.
|
||||
*
|
||||
* Mirrors the profile-metadata editor: text fields + a cover upload, no audio. Any value-for-value
|
||||
* block already on the event is preserved across save (the splits editor is separate).
|
||||
*/
|
||||
class EditPodcastShowViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val title = mutableStateOf("")
|
||||
val description = mutableStateOf("")
|
||||
val author = mutableStateOf("")
|
||||
val email = mutableStateOf("")
|
||||
val coverUrl = mutableStateOf("")
|
||||
val website = mutableStateOf("")
|
||||
val language = mutableStateOf("")
|
||||
val categories = mutableStateOf("")
|
||||
val funding = mutableStateOf("")
|
||||
val copyright = mutableStateOf("")
|
||||
|
||||
/** "episodic" or "serial" (Podcasting 2.0), or blank for unset. */
|
||||
val type = mutableStateOf("")
|
||||
val explicit = mutableStateOf(false)
|
||||
val complete = mutableStateOf(false)
|
||||
val locked = mutableStateOf(false)
|
||||
|
||||
val isSending = mutableStateOf(false)
|
||||
|
||||
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
|
||||
|
||||
val coverMedia = mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
val selectedServer = mutableStateOf<ServerName?>(null)
|
||||
val mediaQualitySlider = mutableStateOf(1)
|
||||
val stripMetadata = mutableStateOf(true)
|
||||
|
||||
/** Editable value-for-value split for the show. */
|
||||
val splitEditor = V4VSplitEditorState()
|
||||
|
||||
/** Fields the editor doesn't surface but must not drop on save. */
|
||||
private var preservedGuid: String? = null
|
||||
private var hasExisting = false
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
if (::account.isInitialized) return
|
||||
this.account = accountViewModel.account
|
||||
this.selectedServer.value = account.settings.defaultFileServer
|
||||
this.stripMetadata.value = account.settings.stripLocationOnUpload
|
||||
|
||||
val address = Address(AppSpecificDataEvent.KIND, account.userProfile().pubkeyHex, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
|
||||
val existing = (LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) }
|
||||
if (existing != null) {
|
||||
hasExisting = true
|
||||
preservedGuid = existing.guid()
|
||||
splitEditor.load(existing.showValue())
|
||||
title.value = existing.showTitle().orEmpty()
|
||||
description.value = existing.showDescription().orEmpty()
|
||||
author.value = existing.showAuthor().orEmpty()
|
||||
email.value = existing.email().orEmpty()
|
||||
coverUrl.value = existing.showImage().orEmpty()
|
||||
website.value = existing.showWebsites().firstOrNull().orEmpty()
|
||||
language.value = existing.language().orEmpty()
|
||||
categories.value = existing.showCategories().joinToString(", ")
|
||||
funding.value = existing.showFundingUrls().joinToString(", ")
|
||||
copyright.value = existing.showCopyright().orEmpty()
|
||||
type.value = existing.type().orEmpty()
|
||||
explicit.value = existing.showIsExplicit()
|
||||
complete.value = existing.showIsComplete()
|
||||
locked.value = existing.isLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPickedCover(uris: ImmutableList<SelectedMedia>) {
|
||||
coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
|
||||
}
|
||||
|
||||
fun clearPickedCover() {
|
||||
coverMedia.value = null
|
||||
coverUrl.value = ""
|
||||
}
|
||||
|
||||
fun isValid(): Boolean = title.value.isNotBlank()
|
||||
|
||||
fun saveAndPublish(
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (isSending.value) return
|
||||
|
||||
val coverOrch = coverMedia.value
|
||||
val server = selectedServer.value
|
||||
if (coverOrch != null && server == null) {
|
||||
accountViewModel.toastManager.toast(
|
||||
"No upload server selected",
|
||||
"Pick a media server in settings before uploading.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val snapshot =
|
||||
Snapshot(
|
||||
content =
|
||||
Podcasting20PodcastMetadata.Content(
|
||||
title = title.value.trim(),
|
||||
description = description.value.trim().ifBlank { null },
|
||||
author = author.value.trim().ifBlank { null },
|
||||
email = email.value.trim().ifBlank { null },
|
||||
image = coverUrl.value.trim().ifBlank { null },
|
||||
language = language.value.trim().ifBlank { null },
|
||||
categories = PodcastComposerMedia.parseCsv(categories.value),
|
||||
explicit = explicit.value.takeIf { it },
|
||||
website = website.value.trim().ifBlank { null },
|
||||
copyright = copyright.value.trim().ifBlank { null },
|
||||
funding = PodcastComposerMedia.parseCsv(funding.value),
|
||||
locked = locked.value.takeIf { it },
|
||||
type = type.value.trim().ifBlank { null },
|
||||
complete = complete.value.takeIf { it },
|
||||
guid = preservedGuid,
|
||||
value = splitEditor.toPodcastValue(),
|
||||
),
|
||||
coverOrchestrator = coverOrch,
|
||||
server = server,
|
||||
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
|
||||
stripMetadata = stripMetadata.value,
|
||||
appContext = context.applicationContext,
|
||||
)
|
||||
|
||||
isSending.value = true
|
||||
accountViewModel.launchSigner {
|
||||
try {
|
||||
val newCoverUrl =
|
||||
snapshot.coverOrchestrator?.let {
|
||||
PodcastComposerMedia.upload(
|
||||
orchestrator = it,
|
||||
kind = "cover",
|
||||
account = account,
|
||||
server = snapshot.server!!,
|
||||
quality = snapshot.quality,
|
||||
stripMetadata = snapshot.stripMetadata,
|
||||
alt = snapshot.content.title,
|
||||
context = snapshot.appContext,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
}
|
||||
|
||||
val finalContent = newCoverUrl?.let { snapshot.content.copyWithImage(it) } ?: snapshot.content
|
||||
account.signAndComputeBroadcast(Podcasting20PodcastMetadata.build(finalContent))
|
||||
|
||||
if (snapshot.coverOrchestrator != null) {
|
||||
account.settings.changeDefaultFileServer(snapshot.server!!)
|
||||
account.settings.changeStripLocationOnUpload(snapshot.stripMetadata)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
coverMedia.value = null
|
||||
if (newCoverUrl != null) coverUrl.value = newCoverUrl
|
||||
}
|
||||
_completionEvents.tryEmit(Unit)
|
||||
} catch (t: Throwable) {
|
||||
accountViewModel.toastManager.toast(
|
||||
"Failed to save podcast",
|
||||
t.message ?: t.javaClass.simpleName,
|
||||
)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { isSending.value = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Snapshot(
|
||||
val content: Podcasting20PodcastMetadata.Content,
|
||||
val coverOrchestrator: MultiOrchestrator?,
|
||||
val server: ServerName?,
|
||||
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
|
||||
val stripMetadata: Boolean,
|
||||
val appContext: Context,
|
||||
)
|
||||
}
|
||||
|
||||
/** Copies a metadata content with a new image URL (Content has no copy() — it's a plain class). */
|
||||
private fun Podcasting20PodcastMetadata.Content.copyWithImage(image: String): Podcasting20PodcastMetadata.Content =
|
||||
Podcasting20PodcastMetadata.Content(
|
||||
title = title,
|
||||
description = description,
|
||||
author = author,
|
||||
email = email,
|
||||
image = image,
|
||||
language = language,
|
||||
categories = categories,
|
||||
explicit = explicit,
|
||||
website = website,
|
||||
copyright = copyright,
|
||||
funding = funding,
|
||||
locked = locked,
|
||||
type = type,
|
||||
complete = complete,
|
||||
guid = guid,
|
||||
value = value,
|
||||
)
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Composer for a Podcasting-2.0 episode. Cover art + audio file upload at the top (or paste URLs),
|
||||
* the core fields (title, summary, duration), and a collapsible "More details" section for the
|
||||
* Podcasting-2.0 extras (season/number, video, transcript, chapters, topics). Create + edit + delete.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewPodcastEpisodeScreen(
|
||||
editDTag: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val vm: NewPodcastEpisodeViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(accountViewModel) { vm.init(accountViewModel, editDTag) }
|
||||
|
||||
StrippingFailureDialog(vm.strippingFailureConfirmation)
|
||||
|
||||
var wantsToPickCover by remember { mutableStateOf(false) }
|
||||
if (wantsToPickCover) {
|
||||
GallerySelectSingle(
|
||||
onImageUri = { picked ->
|
||||
wantsToPickCover = false
|
||||
vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var wantsToPickAudio by remember { mutableStateOf(false) }
|
||||
if (wantsToPickAudio) {
|
||||
AudioFileSelect { picked ->
|
||||
wantsToPickAudio = false
|
||||
vm.setPickedAudio(context, picked)
|
||||
}
|
||||
}
|
||||
|
||||
val isBusy = vm.isSending.value
|
||||
|
||||
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SendingTopBar(
|
||||
titleRes = if (vm.isEditing) R.string.podcast_edit_episode else R.string.podcast_new_episode,
|
||||
onCancel = { nav.popBack() },
|
||||
isActive = { vm.isValid() && !isBusy },
|
||||
onPost = {
|
||||
if (!vm.isValid() || isBusy) return@SendingTopBar
|
||||
vm.saveAndPublish(context, accountViewModel)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
|
||||
|
||||
CoverImagePicker(
|
||||
cover = vm.coverMedia.value,
|
||||
existingUrl = vm.coverUrl.value,
|
||||
onPick = { wantsToPickCover = true },
|
||||
onDelete = { vm.clearPickedCover() },
|
||||
accountViewModel = accountViewModel,
|
||||
enabled = !isBusy,
|
||||
ctaRes = R.string.podcast_cover_upload_cta,
|
||||
hintRes = R.string.podcast_cover_upload_hint,
|
||||
)
|
||||
|
||||
AudioFilePickerRow(
|
||||
pickedName = vm.pickedAudioName.value,
|
||||
onPick = { wantsToPickAudio = true },
|
||||
onClear = { vm.clearPickedAudio() },
|
||||
enabled = !isBusy,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = vm.title.value,
|
||||
onValueChange = { vm.title.value = it },
|
||||
label = { Text(stringRes(R.string.podcast_episode_title_label)) },
|
||||
placeholder = { Text(stringRes(R.string.podcast_episode_title_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||
isError = vm.title.value.isBlank(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = vm.description.value,
|
||||
onValueChange = { vm.description.value = it },
|
||||
label = { Text(stringRes(R.string.podcast_episode_summary_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 4,
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = vm.durationSeconds.value,
|
||||
onValueChange = { input -> vm.durationSeconds.value = input.filter { it.isDigit() } },
|
||||
label = { Text(stringRes(R.string.podcast_episode_duration_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
|
||||
var showMore by rememberSaveable { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { showMore = !showMore }.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_episode_more_details),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = if (showMore) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (showMore) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = vm.season.value,
|
||||
onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } },
|
||||
label = { Text(stringRes(R.string.podcast_episode_season_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = vm.episodeNumber.value,
|
||||
onValueChange = { input -> vm.episodeNumber.value = input.filter { it.isDigit() } },
|
||||
label = { Text(stringRes(R.string.podcast_episode_number_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
}
|
||||
|
||||
UrlField(vm.videoUrl, R.string.podcast_episode_video_label)
|
||||
UrlField(vm.transcriptUrl, R.string.podcast_episode_transcript_label)
|
||||
UrlField(vm.chaptersUrl, R.string.podcast_episode_chapters_label)
|
||||
|
||||
OutlinedTextField(
|
||||
value = vm.topics.value,
|
||||
onValueChange = { vm.topics.value = it },
|
||||
label = { Text(stringRes(R.string.podcast_episode_topics_label)) },
|
||||
placeholder = { Text(stringRes(R.string.podcast_episode_topics_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = vm.audioUrl.value,
|
||||
onValueChange = { vm.audioUrl.value = it },
|
||||
label = { Text(stringRes(R.string.podcast_episode_audio_url_label)) },
|
||||
placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
|
||||
// Episode-level V4V override; leave empty to inherit the show's split.
|
||||
V4VSplitEditor(vm.splitEditor, accountViewModel)
|
||||
}
|
||||
|
||||
if (vm.isEditing) {
|
||||
HorizontalDivider()
|
||||
DeleteEpisodeRow(vm = vm, onDeleted = { nav.popBack() }, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UrlField(
|
||||
state: androidx.compose.runtime.MutableState<String>,
|
||||
labelRes: Int,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.value,
|
||||
onValueChange = { state.value = it },
|
||||
label = { Text(stringRes(labelRes)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioFilePickerRow(
|
||||
pickedName: String?,
|
||||
onPick: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
if (pickedName != null) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp))
|
||||
.let { if (enabled) it.clickable(onClick = onPick) else it }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MusicNote,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_episode_audio_picked),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
TextButton(onClick = onClear) { Text(stringRes(R.string.cancel)) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UploadPlaceholder(
|
||||
iconSymbol = MaterialSymbols.MusicNote,
|
||||
ctaRes = R.string.podcast_episode_audio_upload_cta,
|
||||
hintRes = R.string.podcast_episode_audio_upload_hint,
|
||||
onClick = onPick,
|
||||
aspectRatio = null,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Single audio file via OpenDocument restricted to audio MIME types.
|
||||
@Composable
|
||||
private fun AudioFileSelect(onAudioPicked: (SelectedMedia?) -> Unit) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
onResult = { uri: Uri? -> onAudioPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
|
||||
)
|
||||
LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*")) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteEpisodeRow(
|
||||
vm: NewPodcastEpisodeViewModel,
|
||||
onDeleted: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var confirming by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { confirming = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text(text = stringRes(R.string.podcast_episode_delete))
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirming = false },
|
||||
title = { Text(stringRes(R.string.podcast_episode_delete)) },
|
||||
text = { Text(stringRes(R.string.podcast_episode_delete_confirm)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirming = false
|
||||
accountViewModel.launchSigner { if (vm.deleteLoaded()) onDeleted() }
|
||||
}) {
|
||||
Text(text = stringRes(R.string.podcast_episode_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirming = false }) { Text(stringRes(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Composer for a Podcasting-2.0 episode (`kind:30054`, addressable). Closely mirrors the music-track
|
||||
* composer: the user picks a cover image and an audio file, Save uploads them through Blossom/NIP-96,
|
||||
* then publishes the addressable event with the returned URLs. Power users can paste raw URLs instead.
|
||||
*
|
||||
* Edit mode (`editDTag` resolves from LocalCache) re-publishes under the same `d` tag so it replaces
|
||||
* the prior version in place; the original `pubdate` and any value-for-value block are preserved.
|
||||
*/
|
||||
class NewPodcastEpisodeViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val title = mutableStateOf("")
|
||||
val description = mutableStateOf("")
|
||||
val audioUrl = mutableStateOf("")
|
||||
val coverUrl = mutableStateOf("")
|
||||
val durationSeconds = mutableStateOf("")
|
||||
val episodeNumber = mutableStateOf("")
|
||||
val season = mutableStateOf("")
|
||||
val videoUrl = mutableStateOf("")
|
||||
val transcriptUrl = mutableStateOf("")
|
||||
val chaptersUrl = mutableStateOf("")
|
||||
val topics = mutableStateOf("")
|
||||
|
||||
val isSending = mutableStateOf(false)
|
||||
|
||||
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
|
||||
|
||||
val coverMedia = mutableStateOf<MultiOrchestrator?>(null)
|
||||
val audioMedia = mutableStateOf<MultiOrchestrator?>(null)
|
||||
val pickedAudioName = mutableStateOf<String?>(null)
|
||||
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
val selectedServer = mutableStateOf<ServerName?>(null)
|
||||
val mediaQualitySlider = mutableStateOf(1)
|
||||
val stripMetadata = mutableStateOf(true)
|
||||
|
||||
private var dTag: String? = null
|
||||
private var loadedEvent: Podcasting20EpisodeEvent? = null
|
||||
|
||||
/** Editable value-for-value split for this episode (overrides the show's split). */
|
||||
val splitEditor = V4VSplitEditorState()
|
||||
|
||||
/** Carried across an edit so we don't drop the original publish date on save. */
|
||||
private var preservedPubDate: String? = null
|
||||
|
||||
val isEditing: Boolean
|
||||
get() = loadedEvent != null
|
||||
|
||||
fun init(
|
||||
accountViewModel: AccountViewModel,
|
||||
editDTag: String?,
|
||||
) {
|
||||
if (::account.isInitialized) return
|
||||
this.account = accountViewModel.account
|
||||
this.selectedServer.value = account.settings.defaultFileServer
|
||||
this.stripMetadata.value = account.settings.stripLocationOnUpload
|
||||
|
||||
if (editDTag != null) {
|
||||
val address = Address(Podcasting20EpisodeEvent.KIND, account.userProfile().pubkeyHex, editDTag)
|
||||
(LocalCache.addressables.get(address)?.event as? Podcasting20EpisodeEvent)?.let { existing ->
|
||||
dTag = editDTag
|
||||
loadedEvent = existing
|
||||
preservedPubDate = existing.pubDate()
|
||||
splitEditor.load(existing.value())
|
||||
title.value = existing.title().orEmpty()
|
||||
description.value = existing.description().orEmpty()
|
||||
audioUrl.value =
|
||||
existing
|
||||
.audios()
|
||||
.firstOrNull()
|
||||
?.url
|
||||
.orEmpty()
|
||||
coverUrl.value = existing.image().orEmpty()
|
||||
durationSeconds.value = existing.durationInSeconds()?.toString().orEmpty()
|
||||
episodeNumber.value = existing.number()?.toString().orEmpty()
|
||||
season.value = existing.season()?.toString().orEmpty()
|
||||
videoUrl.value = existing.video()?.url.orEmpty()
|
||||
transcriptUrl.value = existing.transcriptUrl().orEmpty()
|
||||
chaptersUrl.value = existing.chaptersUrl().orEmpty()
|
||||
topics.value = existing.topics().joinToString(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPickedCover(uris: ImmutableList<SelectedMedia>) {
|
||||
coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
|
||||
}
|
||||
|
||||
fun clearPickedCover() {
|
||||
coverMedia.value = null
|
||||
coverUrl.value = ""
|
||||
}
|
||||
|
||||
fun setPickedAudio(
|
||||
context: Context,
|
||||
uri: SelectedMedia?,
|
||||
) {
|
||||
if (uri == null) {
|
||||
audioMedia.value = null
|
||||
pickedAudioName.value = null
|
||||
return
|
||||
}
|
||||
audioMedia.value = MultiOrchestrator(persistentListOf(uri))
|
||||
pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
|
||||
|
||||
val appContext = context.applicationContext
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val probed = PodcastComposerMedia.probeAudio(appContext, uri.uri) ?: return@launch
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
probed.durationSeconds?.let { durationSeconds.value = it.toString() }
|
||||
if (title.value.isBlank()) probed.title?.let { title.value = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPickedAudio() {
|
||||
audioMedia.value = null
|
||||
pickedAudioName.value = null
|
||||
}
|
||||
|
||||
/** Valid with a title and a resolvable audio source (picked file or a pasted URL). */
|
||||
fun isValid(): Boolean = title.value.isNotBlank() && (audioMedia.value != null || audioUrl.value.isNotBlank())
|
||||
|
||||
fun saveAndPublish(
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (isSending.value) return
|
||||
|
||||
val server = selectedServer.value
|
||||
if (server == null) {
|
||||
accountViewModel.toastManager.toast(
|
||||
"No upload server selected",
|
||||
"Pick a media server in settings before uploading.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val snapshot =
|
||||
Snapshot(
|
||||
title = title.value.trim(),
|
||||
description = description.value.trim().ifBlank { null },
|
||||
durationSeconds = durationSeconds.value.trim().toLongOrNull(),
|
||||
episodeNumber = episodeNumber.value.trim().toIntOrNull(),
|
||||
season = season.value.trim().toIntOrNull(),
|
||||
videoUrl = videoUrl.value.trim().ifBlank { null },
|
||||
transcriptUrl = transcriptUrl.value.trim().ifBlank { null },
|
||||
chaptersUrl = chaptersUrl.value.trim().ifBlank { null },
|
||||
topics = PodcastComposerMedia.parseCsv(topics.value),
|
||||
value = splitEditor.toPodcastValue(),
|
||||
coverOrchestrator = coverMedia.value,
|
||||
audioOrchestrator = audioMedia.value,
|
||||
existingCoverUrl = coverUrl.value.trim().ifBlank { null },
|
||||
existingAudioUrl = audioUrl.value.trim(),
|
||||
server = server,
|
||||
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
|
||||
stripMetadata = stripMetadata.value,
|
||||
appContext = context.applicationContext,
|
||||
)
|
||||
|
||||
isSending.value = true
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
try {
|
||||
val (newCoverUrl, newAudioUrl) = performParallelUploads(snapshot)
|
||||
val finalCoverUrl = newCoverUrl ?: snapshot.existingCoverUrl
|
||||
val finalAudioUrl = newAudioUrl ?: snapshot.existingAudioUrl
|
||||
if (finalAudioUrl.isBlank()) {
|
||||
accountViewModel.toastManager.toast(
|
||||
"Audio upload failed",
|
||||
"No audio URL ended up available for the published episode.",
|
||||
)
|
||||
return@launchSigner
|
||||
}
|
||||
|
||||
publishEpisode(snapshot, finalCoverUrl, finalAudioUrl)
|
||||
|
||||
account.settings.changeDefaultFileServer(snapshot.server)
|
||||
account.settings.changeStripLocationOnUpload(snapshot.stripMetadata)
|
||||
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
coverMedia.value = null
|
||||
audioMedia.value = null
|
||||
pickedAudioName.value = null
|
||||
if (newCoverUrl != null) coverUrl.value = newCoverUrl
|
||||
if (newAudioUrl != null) audioUrl.value = newAudioUrl
|
||||
}
|
||||
_completionEvents.tryEmit(Unit)
|
||||
} catch (t: Throwable) {
|
||||
accountViewModel.toastManager.toast(
|
||||
"Failed to publish episode",
|
||||
t.message ?: t.javaClass.simpleName,
|
||||
)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { isSending.value = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Snapshot(
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val durationSeconds: Long?,
|
||||
val episodeNumber: Int?,
|
||||
val season: Int?,
|
||||
val videoUrl: String?,
|
||||
val transcriptUrl: String?,
|
||||
val chaptersUrl: String?,
|
||||
val topics: List<String>,
|
||||
val value: PodcastValue?,
|
||||
val coverOrchestrator: MultiOrchestrator?,
|
||||
val audioOrchestrator: MultiOrchestrator?,
|
||||
val existingCoverUrl: String?,
|
||||
val existingAudioUrl: String,
|
||||
val server: ServerName,
|
||||
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
|
||||
val stripMetadata: Boolean,
|
||||
val appContext: Context,
|
||||
)
|
||||
|
||||
private suspend fun performParallelUploads(snapshot: Snapshot): Pair<String?, String?> =
|
||||
coroutineScope {
|
||||
val deferreds =
|
||||
listOf(
|
||||
async { snapshot.coverOrchestrator?.let { uploadOne(it, "cover", snapshot) } },
|
||||
async { snapshot.audioOrchestrator?.let { uploadOne(it, "audio", snapshot) } },
|
||||
)
|
||||
val results = deferreds.awaitAll()
|
||||
results[0] to results[1]
|
||||
}
|
||||
|
||||
private suspend fun uploadOne(
|
||||
orchestrator: MultiOrchestrator,
|
||||
kind: String,
|
||||
snapshot: Snapshot,
|
||||
): String =
|
||||
PodcastComposerMedia.upload(
|
||||
orchestrator = orchestrator,
|
||||
kind = kind,
|
||||
account = account,
|
||||
server = snapshot.server,
|
||||
quality = snapshot.quality,
|
||||
stripMetadata = snapshot.stripMetadata,
|
||||
alt = snapshot.title.ifBlank { null },
|
||||
context = snapshot.appContext,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
private suspend fun publishEpisode(
|
||||
snapshot: Snapshot,
|
||||
coverUrl: String?,
|
||||
audioUrl: String,
|
||||
) {
|
||||
// Audio/video MIME isn't tracked once uploaded (the URL is enough; the player sniffs the
|
||||
// type). The summary lives in the `description` tag; `content` stays empty so the episode
|
||||
// renderer doesn't show the same text twice (it renders the tag and the markdown body apart).
|
||||
val template =
|
||||
Podcasting20EpisodeEvent.build(
|
||||
dTag = dTag ?: PodcastComposerMedia.generateDTag("episode"),
|
||||
title = snapshot.title,
|
||||
audios = listOf(PodcastAudio(audioUrl, null)),
|
||||
pubdate = preservedPubDate ?: PodcastComposerMedia.rfc2822Now(),
|
||||
description = snapshot.description,
|
||||
image = coverUrl?.ifBlank { null },
|
||||
durationInSeconds = snapshot.durationSeconds,
|
||||
video = snapshot.videoUrl?.let { PodcastAudio(it, null) },
|
||||
episodeNumber = snapshot.episodeNumber,
|
||||
season = snapshot.season,
|
||||
transcriptUrl = snapshot.transcriptUrl,
|
||||
chaptersUrl = snapshot.chaptersUrl,
|
||||
value = snapshot.value,
|
||||
topics = snapshot.topics,
|
||||
)
|
||||
account.signAndComputeBroadcast(template)
|
||||
}
|
||||
|
||||
suspend fun deleteLoaded(): Boolean {
|
||||
val target = loadedEvent ?: return false
|
||||
val note = LocalCache.getOrCreateAddressableNote(target.address())
|
||||
account.delete(note)
|
||||
return true
|
||||
}
|
||||
}
|
||||
+204
@@ -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/*")) }
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Composer for a Podcasting-2.0 trailer (`kind:30055`, addressable). A short preview clip: a title,
|
||||
* one audio/video file (uploaded or pasted as a URL), and an optional season number. Always
|
||||
* create-new — each trailer gets a fresh `d` tag.
|
||||
*/
|
||||
class NewPodcastTrailerViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val title = mutableStateOf("")
|
||||
val url = mutableStateOf("")
|
||||
val season = mutableStateOf("")
|
||||
val pickedMimeType = mutableStateOf<String?>(null)
|
||||
|
||||
val isSending = mutableStateOf(false)
|
||||
|
||||
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
|
||||
|
||||
val media = mutableStateOf<MultiOrchestrator?>(null)
|
||||
val pickedName = mutableStateOf<String?>(null)
|
||||
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
val selectedServer = mutableStateOf<ServerName?>(null)
|
||||
val mediaQualitySlider = mutableStateOf(1)
|
||||
val stripMetadata = mutableStateOf(true)
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
if (::account.isInitialized) return
|
||||
this.account = accountViewModel.account
|
||||
this.selectedServer.value = account.settings.defaultFileServer
|
||||
this.stripMetadata.value = account.settings.stripLocationOnUpload
|
||||
}
|
||||
|
||||
fun setPickedMedia(uri: SelectedMedia?) {
|
||||
if (uri == null) {
|
||||
media.value = null
|
||||
pickedName.value = null
|
||||
pickedMimeType.value = null
|
||||
return
|
||||
}
|
||||
media.value = MultiOrchestrator(persistentListOf(uri))
|
||||
pickedName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
|
||||
pickedMimeType.value = uri.mimeType
|
||||
}
|
||||
|
||||
fun clearPickedMedia() {
|
||||
media.value = null
|
||||
pickedName.value = null
|
||||
pickedMimeType.value = null
|
||||
}
|
||||
|
||||
fun isValid(): Boolean = title.value.isNotBlank() && (media.value != null || url.value.isNotBlank())
|
||||
|
||||
fun saveAndPublish(
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (isSending.value) return
|
||||
val server = selectedServer.value
|
||||
if (server == null) {
|
||||
accountViewModel.toastManager.toast(
|
||||
"No upload server selected",
|
||||
"Pick a media server in settings before uploading.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val titleSnap = title.value.trim()
|
||||
val urlSnap = url.value.trim()
|
||||
val seasonSnap = season.value.trim().toIntOrNull()
|
||||
val mimeSnap = pickedMimeType.value
|
||||
val mediaSnap = media.value
|
||||
val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value)
|
||||
val strip = stripMetadata.value
|
||||
val appContext = context.applicationContext
|
||||
|
||||
isSending.value = true
|
||||
accountViewModel.launchSigner {
|
||||
try {
|
||||
val finalUrl =
|
||||
if (mediaSnap != null) {
|
||||
PodcastComposerMedia.upload(
|
||||
orchestrator = mediaSnap,
|
||||
kind = "trailer",
|
||||
account = account,
|
||||
server = server,
|
||||
quality = quality,
|
||||
stripMetadata = strip,
|
||||
alt = titleSnap.ifBlank { null },
|
||||
context = appContext,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
} else {
|
||||
urlSnap
|
||||
}
|
||||
if (finalUrl.isBlank()) {
|
||||
accountViewModel.toastManager.toast("Trailer upload failed", "No URL was available for the trailer.")
|
||||
return@launchSigner
|
||||
}
|
||||
|
||||
val template =
|
||||
Podcasting20TrailerEvent.build(
|
||||
dTag = PodcastComposerMedia.generateDTag("trailer"),
|
||||
title = titleSnap,
|
||||
url = finalUrl,
|
||||
pubdate = PodcastComposerMedia.rfc2822Now(),
|
||||
mimeType = mimeSnap,
|
||||
season = seasonSnap,
|
||||
)
|
||||
account.signAndComputeBroadcast(template)
|
||||
|
||||
account.settings.changeDefaultFileServer(server)
|
||||
account.settings.changeStripLocationOnUpload(strip)
|
||||
|
||||
_completionEvents.tryEmit(Unit)
|
||||
} catch (t: Throwable) {
|
||||
accountViewModel.toastManager.toast("Failed to publish trailer", t.message ?: t.javaClass.simpleName)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { isSending.value = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+319
@@ -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
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Shared upload + media-probe mechanics for the Podcasting-2.0 composers (episode, show, trailer).
|
||||
* Mirrors the music-track composer's approach but kept in one place so all three authoring
|
||||
* ViewModels resolve a picked file to a hosted URL the same way.
|
||||
*/
|
||||
object PodcastComposerMedia {
|
||||
class UploadException(
|
||||
kind: String,
|
||||
details: String,
|
||||
) : Exception("$kind upload failed: $details")
|
||||
|
||||
/**
|
||||
* Uploads a single picked file (cover image or audio) through the user's media server and
|
||||
* returns the hosted URL. Throws [UploadException] if the server rejects it or returns no URL.
|
||||
*/
|
||||
suspend fun upload(
|
||||
orchestrator: MultiOrchestrator,
|
||||
kind: String,
|
||||
account: Account,
|
||||
server: ServerName,
|
||||
quality: CompressorQuality,
|
||||
stripMetadata: Boolean,
|
||||
alt: String?,
|
||||
context: Context,
|
||||
onStrippingFailed: suspend () -> Boolean,
|
||||
): String {
|
||||
val res =
|
||||
orchestrator.upload(
|
||||
alt = alt,
|
||||
contentWarningReason = null,
|
||||
mediaQuality = quality,
|
||||
server = server,
|
||||
account = account,
|
||||
context = context,
|
||||
useH265 = false,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = onStrippingFailed,
|
||||
)
|
||||
if (!res.allGood) throw UploadException(kind, formatUploadErrors(res.errors, context))
|
||||
return firstUploadedUrl(res.successful)
|
||||
?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.")
|
||||
}
|
||||
|
||||
private fun firstUploadedUrl(successful: List<UploadingState.Finished>): String? =
|
||||
successful
|
||||
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
|
||||
?.url
|
||||
|
||||
private fun formatUploadErrors(
|
||||
errors: List<UploadingState.Error>,
|
||||
context: Context,
|
||||
): String =
|
||||
errors
|
||||
.map { context.getString(it.errorResource, *it.params) }
|
||||
.distinct()
|
||||
.joinToString(".\n")
|
||||
|
||||
/** Audio metadata read off a picked file, used to auto-fill the composer. Any field may be null. */
|
||||
class ProbedAudio(
|
||||
val durationSeconds: Int?,
|
||||
val title: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads duration + title from a picked audio file via [MediaMetadataRetriever] (heavy — call off
|
||||
* the main thread). Returns null if the provider rejects it or the file isn't a real container.
|
||||
*/
|
||||
fun probeAudio(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
): ProbedAudio? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
retriever.setDataSource(context, uri)
|
||||
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
||||
ProbedAudio(
|
||||
durationSeconds = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } },
|
||||
title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null },
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Current time as an RFC2822 date string (`Tue, 24 Jun 2025 12:00:00 GMT`) — the spec's `pubdate`. */
|
||||
fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")))
|
||||
|
||||
/** A fresh, stable `d` tag for a new addressable episode/trailer. */
|
||||
fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}"
|
||||
|
||||
/** Splits a comma-separated text field into a clean list (trimmed, no blanks). */
|
||||
fun parseCsv(text: String): List<String> = text.split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.podcasts.RecipientDraft
|
||||
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
|
||||
/**
|
||||
* Editor for a Podcasting-2.0 value-for-value split. Recipients are added the Amethyst-native way —
|
||||
* search for a Nostr user and they're rendered with avatar + name, their lightning address resolved
|
||||
* automatically — with a manual "add address" fallback for raw lightning addresses or node keysend
|
||||
* destinations. Each recipient's share of incoming sats is shown live as a percentage of the total
|
||||
* weight. Drives a [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue].
|
||||
*/
|
||||
@Composable
|
||||
fun V4VSplitEditor(
|
||||
state: V4VSplitEditorState,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val total = state.totalSplit()
|
||||
val userSuggestions =
|
||||
remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) }
|
||||
var search by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_value_for_value),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.recipients.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_value_editor_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
|
||||
state.recipients.forEach { draft ->
|
||||
if (draft.user.value != null) {
|
||||
UserRecipientCard(draft, total, accountViewModel, onRemove = { state.remove(draft) })
|
||||
} else {
|
||||
ManualRecipientCard(draft, total, onRemove = { state.remove(draft) })
|
||||
}
|
||||
}
|
||||
|
||||
// Search a Nostr user to add (resolves their lightning address). Beautiful path.
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { newValue ->
|
||||
search = newValue
|
||||
if (newValue.length > 2) userSuggestions.processCurrentWord(newValue) else userSuggestions.reset()
|
||||
},
|
||||
label = { Text(stringRes(R.string.podcast_value_search_user)) },
|
||||
placeholder = { Text(stringRes(R.string.podcast_value_search_user_hint)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
if (search.length > 2) {
|
||||
ShowUserSuggestionList(
|
||||
userSuggestions = userSuggestions,
|
||||
onSelect = { user ->
|
||||
val added = state.addUser(user)
|
||||
if (!added) {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.podcast_value_for_value,
|
||||
R.string.podcast_value_user_no_lnaddress,
|
||||
)
|
||||
}
|
||||
search = ""
|
||||
userSuggestions.reset()
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = SuggestionListDefaultHeightPage,
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback for raw destinations (a node pubkey for keysend, or a non-Nostr lightning address).
|
||||
TextButton(onClick = { state.addManual() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(text = stringRes(R.string.podcast_value_add_address), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientShell(
|
||||
total: Int,
|
||||
draft: RecipientDraft,
|
||||
onRemove: () -> Unit,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val weight =
|
||||
draft.split.value
|
||||
.trim()
|
||||
.toIntOrNull() ?: 0
|
||||
val percent = if (total > 0 && weight > 0) weight * 100 / total else 0
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
content()
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_value_split_percent, percent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(R.string.podcast_value_remove_recipient),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
WeightAndFeeRow(draft, weight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserRecipientCard(
|
||||
draft: RecipientDraft,
|
||||
total: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val user = draft.user.value ?: return
|
||||
RecipientShell(total, draft, onRemove) {
|
||||
BaseUserPicture(user, Size40dp, accountViewModel = accountViewModel)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
UsernameDisplay(user, accountViewModel = accountViewModel)
|
||||
val lud = user.lnAddress()
|
||||
Text(
|
||||
text = lud ?: stringRes(R.string.podcast_value_user_no_lnaddress),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManualRecipientCard(
|
||||
draft: RecipientDraft,
|
||||
total: Int,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val isNode by draft.isNode
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val weight =
|
||||
draft.split.value
|
||||
.trim()
|
||||
.toIntOrNull() ?: 0
|
||||
val percent = if (total > 0 && weight > 0) weight * 100 / total else 0
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_value_split_percent, percent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(R.string.podcast_value_remove_recipient),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = draft.name.value,
|
||||
onValueChange = { draft.name.value = it },
|
||||
label = { Text(stringRes(R.string.podcast_value_recipient_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = !isNode,
|
||||
onClick = { draft.isNode.value = false },
|
||||
label = { Text(stringRes(R.string.podcast_value_type_lnaddress)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = isNode,
|
||||
onClick = { draft.isNode.value = true },
|
||||
label = { Text(stringRes(R.string.podcast_value_type_node)) },
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = draft.address.value,
|
||||
onValueChange = { draft.address.value = it },
|
||||
label = { Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey) else stringRes(R.string.podcast_value_lnaddress)) },
|
||||
placeholder = {
|
||||
Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = draft.address.value.isBlank(),
|
||||
)
|
||||
|
||||
WeightAndFeeRow(draft, weight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeightAndFeeRow(
|
||||
draft: RecipientDraft,
|
||||
weight: Int,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = draft.split.value,
|
||||
onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } },
|
||||
label = { Text(stringRes(R.string.podcast_value_weight)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
isError = weight <= 0,
|
||||
)
|
||||
FilterChip(
|
||||
selected = draft.fee.value,
|
||||
onClick = { draft.fee.value = !draft.fee.value },
|
||||
label = { Text(stringRes(R.string.podcast_value_fee)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
-8
@@ -23,15 +23,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
|
||||
|
||||
/**
|
||||
* Every episode of a single podcast. Per NIP-F4 each podcast is its own keypair, so all
|
||||
* episodes (kind 54) of a show are authored by [podcastPubkey]. Episodes are regular events,
|
||||
* so they live in `LocalCache.notes`. Most-recent-first via [sortedByDefaultFeedOrder].
|
||||
* Everything a single podcast (authored by [podcastPubkey]) publishes for its show page: NIP-F4
|
||||
* episodes (kind 54, regular events in `LocalCache.notes`), Podcasting-2.0 episodes (kind 30054)
|
||||
* and Podcasting-2.0 trailers (kind 30055, both addressable events in `LocalCache.addressables`).
|
||||
* Episodes go through the shared [PodcastEpisode] interface; trailers are matched by their concrete
|
||||
* type and rendered distinctly. In every model the show's pubkey authors its own content.
|
||||
* Most-recent-first via [sortedByDefaultFeedOrder].
|
||||
*/
|
||||
class OnePodcastEpisodesFeedFilter(
|
||||
val podcastPubkey: HexKey,
|
||||
@@ -41,18 +47,27 @@ class OnePodcastEpisodesFeedFilter(
|
||||
override fun feedKey(): String = "podcast-" + podcastPubkey
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes =
|
||||
val regular =
|
||||
cache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it)
|
||||
}
|
||||
return sort(notes)
|
||||
val episodes =
|
||||
cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it ->
|
||||
acceptableEvent(it)
|
||||
}
|
||||
val trailers =
|
||||
cache.addressables.filterIntoSet(Podcasting20TrailerEvent.KIND) { _, it ->
|
||||
acceptableEvent(it)
|
||||
}
|
||||
return sort(regular + episodes + trailers)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { acceptableEvent(it) }
|
||||
|
||||
private fun acceptableEvent(note: Note): Boolean {
|
||||
val noteEvent = note.event
|
||||
return noteEvent is PodcastEpisodeEvent &&
|
||||
val noteEvent = note.event ?: return false
|
||||
val isShowContent = noteEvent is PodcastEpisode || noteEvent is Podcasting20TrailerEvent
|
||||
return isShowContent &&
|
||||
noteEvent.pubKey == podcastPubkey &&
|
||||
!note.isHiddenFor(account.hiddenUsers.flow.value) &&
|
||||
account.isAcceptable(note)
|
||||
|
||||
+14
-8
@@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
|
||||
|
||||
/**
|
||||
* Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike
|
||||
* music tracks/playlists which are addressable. Same follow-list + hidden/blocked
|
||||
* gate as MusicTracksFeedFilter.
|
||||
* Merges the two podcast-episode kinds into one list. NIP-F4 episodes (kind 54) are regular
|
||||
* events in `LocalCache.notes`; Podcasting-2.0 episodes (kind 30054) are addressable events in
|
||||
* `LocalCache.addressables`. Both implement [PodcastEpisode], so a single accept gate covers
|
||||
* them. Same follow-list + hidden/blocked gate as MusicTracksFeedFilter.
|
||||
*/
|
||||
class PodcastEpisodesFeedFilter(
|
||||
val account: Account,
|
||||
@@ -54,11 +56,15 @@ class PodcastEpisodesFeedFilter(
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
val notes =
|
||||
val regular =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
accept(it, params)
|
||||
}
|
||||
return sort(notes)
|
||||
val addressable =
|
||||
LocalCache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it ->
|
||||
accept(it, params)
|
||||
}
|
||||
return sort(regular + addressable)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
@@ -78,8 +84,8 @@ class PodcastEpisodesFeedFilter(
|
||||
note: Note,
|
||||
params: FilterByListParams,
|
||||
): Boolean {
|
||||
val noteEvent = note.event
|
||||
return noteEvent is PodcastEpisodeEvent &&
|
||||
val noteEvent = note.event ?: return false
|
||||
return noteEvent is PodcastEpisode &&
|
||||
params.match(noteEvent, note.relays) &&
|
||||
(params.isHiddenList || account.isAcceptable(note))
|
||||
}
|
||||
|
||||
+14
-6
@@ -28,11 +28,15 @@ import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastShowEvent
|
||||
|
||||
/**
|
||||
* Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in
|
||||
* `LocalCache.addressables` — same shape as the music-playlists filter.
|
||||
* Merges show-level podcast metadata from both drafts into one list. NIP-F4 shows (kind 10154)
|
||||
* and Podcasting-2.0 shows (kind 30078 NIP-78 app-data with `d="podcast-metadata"`) are both
|
||||
* replaceable, so they live in `LocalCache.addressables`. [isPodcastShowEvent] gates inclusion
|
||||
* (the kind-30078 scan would otherwise see unrelated app-data, so the `d`-tag check matters).
|
||||
*/
|
||||
class PodcastsFeedFilter(
|
||||
val account: Account,
|
||||
@@ -53,11 +57,15 @@ class PodcastsFeedFilter(
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
val notes =
|
||||
val f4 =
|
||||
LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it ->
|
||||
accept(it, params)
|
||||
}
|
||||
return sort(notes)
|
||||
val podcasting20 =
|
||||
LocalCache.addressables.filterIntoSet(AppSpecificDataEvent.KIND) { _, it ->
|
||||
accept(it, params)
|
||||
}
|
||||
return sort(f4 + podcasting20)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
@@ -77,8 +85,8 @@ class PodcastsFeedFilter(
|
||||
note: Note,
|
||||
params: FilterByListParams,
|
||||
): Boolean {
|
||||
val noteEvent = note.event
|
||||
return noteEvent is PodcastMetadataEvent &&
|
||||
val noteEvent = note.event ?: return false
|
||||
return isPodcastShowEvent(noteEvent) &&
|
||||
params.match(noteEvent, note.relays) &&
|
||||
(params.isHiddenList || account.isAcceptable(note))
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
/**
|
||||
* REQ for the logged-in creator's own Podcasting-2.0 catalog, so the authoring hub reliably shows
|
||||
* their show, episodes and trailers even on a fresh install (rather than only what happens to be in
|
||||
* [LocalCache]). Queried on the creator's own outbox relays.
|
||||
*
|
||||
* Two separate filters per relay, and they can't be merged:
|
||||
* - episodes (kind 30054) + trailers (kind 30055) by author, with no tag constraint; and
|
||||
* - the show metadata (kind 30078) constrained to `#d=["podcast-metadata"]`. That NIP-78 app-data
|
||||
* kind is heavily overloaded, so without the `#d` constraint this REQ would pull every app's data
|
||||
* (including the user's private settings) for the pubkey. The constraint must stay off the episodes
|
||||
* filter, since each episode/trailer carries its own `d` tag — a combined `#d` would match nothing.
|
||||
*/
|
||||
fun filterMyPodcast(
|
||||
user: User,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val relays =
|
||||
user.outboxRelays()?.ifEmpty { null }
|
||||
?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
|
||||
|
||||
val authors = listOf(user.pubkeyHex)
|
||||
|
||||
return relays.flatMap { relay ->
|
||||
val sinceTime = since?.get(relay)?.time
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authors,
|
||||
kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND),
|
||||
since = sinceTime,
|
||||
limit = 200,
|
||||
),
|
||||
),
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authors,
|
||||
kinds = listOf(AppSpecificDataEvent.KIND),
|
||||
tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)),
|
||||
since = sinceTime,
|
||||
limit = 10,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
-13
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
class MyPodcastFeedSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<MyPodcastQueryState>,
|
||||
) : PerUserEoseManager<MyPodcastQueryState>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: MyPodcastQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> = filterMyPodcast(user(key), since)
|
||||
|
||||
override fun user(key: MyPodcastQueryState) = key.user
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// Keyed by the logged-in creator's own pubkey. Drives the authoring hub's REQ for the creator's
|
||||
// own Podcasting-2.0 catalog (episodes, trailers, and the show-metadata event).
|
||||
class MyPodcastQueryState(
|
||||
val user: User,
|
||||
)
|
||||
|
||||
class MyPodcastFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<MyPodcastQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
MyPodcastFeedSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
+17
-9
@@ -30,8 +30,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_METADATA_D_FILTER
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity
|
||||
@@ -52,22 +54,28 @@ fun makePodcastsFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince)
|
||||
): List<RelayBasedFilter> =
|
||||
// Two REQs: NIP-F4 shows (kind:10154, no tag constraint) plus Podcasting-2.0 shows
|
||||
// (kind:30078, constrained to `#d=["podcast-metadata"]` so the overloaded app-data kind
|
||||
// doesn't flood the feed). Both land in the merged PodcastsFeedFilter.
|
||||
makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) +
|
||||
makePodcastFilter(feedSettings, PODCASTING20_METADATA_KINDS, since, defaultSince, PODCAST_METADATA_D_FILTER)
|
||||
|
||||
private fun makePodcastFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long?,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
when (feedSettings) {
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince)
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince, additionalTags)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
+35
-1
@@ -27,14 +27,42 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
|
||||
// Each podcast screen issues its own REQ keyed on its own follow-list selector — keep
|
||||
// the kind lists split so episodes and show metadata never co-mingle in one filter.
|
||||
internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND)
|
||||
// Episodes span both podcast drafts: NIP-F4 kind 54 (podcast-is-a-keypair) and Podcasting-2.0
|
||||
// kind 30054 (creator-is-a-keypair). Both are authored by the followed pubkey, so the same
|
||||
// author-scoped REQ pulls them into one merged feed.
|
||||
internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND)
|
||||
internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND)
|
||||
|
||||
// Podcasting-2.0 stores show metadata as a kind:30078 NIP-78 app-data event. That kind is
|
||||
// heavily overloaded, so this REQ MUST be constrained by `#d=["podcast-metadata"]` (see
|
||||
// [PODCAST_METADATA_D_FILTER]) or it would pull every client's app-data. Kept separate from
|
||||
// [PODCAST_KINDS] because the NIP-F4 kind:10154 metadata must NOT carry the `#d` constraint.
|
||||
internal val PODCASTING20_METADATA_KINDS = listOf(AppSpecificDataEvent.KIND)
|
||||
internal val PODCAST_METADATA_D_FILTER = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG))
|
||||
|
||||
/**
|
||||
* Merges two relay-filter tag maps, unioning the value lists per key. Used to layer an extra
|
||||
* constraint (e.g. `#d`) onto a variant that already pins its own tags (`#t`, `#g`, `#a`).
|
||||
* A null on either side passes the other through unchanged.
|
||||
*/
|
||||
internal fun mergeFilterTags(
|
||||
base: Map<String, List<String>>?,
|
||||
extra: Map<String, List<String>>?,
|
||||
): Map<String, List<String>>? =
|
||||
when {
|
||||
base == null -> extra
|
||||
extra == null -> base
|
||||
else -> (base.keys + extra.keys).associateWith { (base[it].orEmpty() + extra[it].orEmpty()).distinct() }
|
||||
}
|
||||
|
||||
// NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is
|
||||
// its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list —
|
||||
// they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent).
|
||||
@@ -47,6 +75,7 @@ fun filterPodcastEventsByAuthors(
|
||||
kinds: List<Int>,
|
||||
authors: Set<HexKey>,
|
||||
since: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val authorList = authors.sorted()
|
||||
return listOf(
|
||||
@@ -56,6 +85,7 @@ fun filterPodcastEventsByAuthors(
|
||||
Filter(
|
||||
authors = authorList,
|
||||
kinds = kinds,
|
||||
tags = additionalTags,
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
@@ -68,6 +98,7 @@ fun filterPodcastEventsByAuthors(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -81,6 +112,7 @@ fun filterPodcastEventsByAuthors(
|
||||
kinds = kinds,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
additionalTags = additionalTags,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
@@ -91,6 +123,7 @@ fun filterPodcastEventsByMutedAuthors(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -104,6 +137,7 @@ fun filterPodcastEventsByMutedAuthors(
|
||||
kinds = kinds,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
additionalTags = additionalTags,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
+8
-2
@@ -33,6 +33,7 @@ fun filterPodcastEventsFromAllCommunities(
|
||||
kinds: List<Int>,
|
||||
communities: Set<String>,
|
||||
since: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val communityList = communities.sorted()
|
||||
val kindsAsStrings = kinds.map { it.toString() }
|
||||
@@ -57,7 +58,7 @@ fun filterPodcastEventsFromAllCommunities(
|
||||
filter =
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
tags = mapOf("a" to communityList),
|
||||
tags = mergeFilterTags(mapOf("a" to communityList), additionalTags),
|
||||
limit = communityList.size * 20,
|
||||
since = since,
|
||||
),
|
||||
@@ -70,6 +71,7 @@ fun filterPodcastEventsByAllCommunities(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (communitySet.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -79,6 +81,7 @@ fun filterPodcastEventsByAllCommunities(
|
||||
kinds = kinds,
|
||||
communities = it.value.communities,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
additionalTags = additionalTags,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -89,6 +92,7 @@ fun filterPodcastEventsFromCommunity(
|
||||
community: String,
|
||||
authors: Set<String>?,
|
||||
since: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val authorList = authors?.sorted()
|
||||
val kindsAsStrings = kinds.map { it.toString() }
|
||||
@@ -114,7 +118,7 @@ fun filterPodcastEventsFromCommunity(
|
||||
Filter(
|
||||
authors = authorList,
|
||||
kinds = kinds,
|
||||
tags = mapOf("a" to listOf(community)),
|
||||
tags = mergeFilterTags(mapOf("a" to listOf(community)), additionalTags),
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
@@ -127,6 +131,7 @@ fun filterPodcastEventsByCommunity(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (communitySet.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -137,6 +142,7 @@ fun filterPodcastEventsByCommunity(
|
||||
community = it.value.community,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
additionalTags = additionalTags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -29,6 +29,7 @@ fun filterPodcastEventsByFollows(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (followsSet.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -38,7 +39,7 @@ fun filterPodcastEventsByFollows(
|
||||
|
||||
listOfNotNull(
|
||||
it.value.authors?.let { authors ->
|
||||
filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay)
|
||||
filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay, additionalTags)
|
||||
},
|
||||
).flatten()
|
||||
}
|
||||
|
||||
+4
-1
@@ -31,6 +31,7 @@ fun filterPodcastEventsByGeohashes(
|
||||
kinds: List<Int>,
|
||||
geotags: Set<String>,
|
||||
since: Long?,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geotags.isEmpty()) return emptyList()
|
||||
|
||||
@@ -40,7 +41,7 @@ fun filterPodcastEventsByGeohashes(
|
||||
filter =
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
tags = mapOf("g" to geotags.sorted()),
|
||||
tags = mergeFilterTags(mapOf("g" to geotags.sorted()), additionalTags),
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
@@ -53,6 +54,7 @@ fun filterPodcastEventsByGeohashes(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long?,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geoSet.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -66,6 +68,7 @@ fun filterPodcastEventsByGeohashes(
|
||||
kinds = kinds,
|
||||
geotags = it.value.geotags,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
additionalTags = additionalTags,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
+4
-1
@@ -31,6 +31,7 @@ fun filterPodcastEventsByHashtag(
|
||||
kinds: List<Int>,
|
||||
hashtags: Set<String>,
|
||||
since: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
@@ -38,7 +39,7 @@ fun filterPodcastEventsByHashtag(
|
||||
filter =
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
tags = mapOf("t" to hashtags.sorted()),
|
||||
tags = mergeFilterTags(mapOf("t" to hashtags.sorted()), additionalTags),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
@@ -50,6 +51,7 @@ fun filterPodcastEventsByHashtag(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (hashtagSet.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -63,6 +65,7 @@ fun filterPodcastEventsByHashtag(
|
||||
kinds = kinds,
|
||||
hashtags = it.value.hashtags,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
additionalTags = additionalTags,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
+2
@@ -30,6 +30,7 @@ fun filterPodcastEventsGlobal(
|
||||
kinds: List<Int>,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
additionalTags: Map<String, List<String>>? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.set.isEmpty()) return emptyList()
|
||||
|
||||
@@ -42,6 +43,7 @@ fun filterPodcastEventsGlobal(
|
||||
filter =
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
tags = additionalTags,
|
||||
limit = 200,
|
||||
since = sinceForRelay,
|
||||
),
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
@@ -995,6 +995,118 @@
|
||||
<string name="route_podcasts">Podcasts</string>
|
||||
<string name="podcast_view_episodes">View episodes</string>
|
||||
<string name="podcast_no_episodes">No episodes found yet</string>
|
||||
<string name="podcast_trailer">Trailer</string>
|
||||
<string name="podcast_trailer_season">Season %1$d</string>
|
||||
<string name="podcast_explicit">Explicit</string>
|
||||
<string name="podcast_completed">Completed</string>
|
||||
<string name="podcast_premium">Premium</string>
|
||||
<string name="podcast_support_show">Support the show</string>
|
||||
<string name="podcast_by_author">by %1$s</string>
|
||||
<string name="podcast_season_episode">S%1$d · E%2$d</string>
|
||||
<string name="podcast_episode_number">Ep %1$d</string>
|
||||
<string name="podcast_season">Season %1$d</string>
|
||||
<string name="podcast_video">Video</string>
|
||||
<string name="podcast_transcript">Transcript</string>
|
||||
<string name="podcast_chapters">Chapters</string>
|
||||
<string name="podcast_value_for_value">Value-for-Value</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_zap_split_hint">Zaps to this are split between:</string>
|
||||
<string name="podcast_hosts_and_guests">Hosts & Guests</string>
|
||||
<string name="podcast_play_soundbite">Play highlight</string>
|
||||
<string name="podcast_top_supporters">Top Supporters</string>
|
||||
<plurals name="podcast_chapters_count">
|
||||
<item quantity="one">%1$d chapter</item>
|
||||
<item quantity="other">%1$d chapters</item>
|
||||
</plurals>
|
||||
<string name="podcast_role_host">Host</string>
|
||||
<string name="podcast_role_cohost">Co-host</string>
|
||||
<string name="podcast_role_editor">Editor</string>
|
||||
<string name="podcast_author_verified">Verified author</string>
|
||||
<string name="podcast_value_error_title">Value-for-Value error</string>
|
||||
<string name="podcast_value_no_recipients">This podcast has no payable value recipients.</string>
|
||||
<string name="podcast_value_keysend_requires_nwc">Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients.</string>
|
||||
<string name="podcast_value_stream">Stream sats</string>
|
||||
<string name="podcast_value_stream_rate">%1$d sats/min</string>
|
||||
<string name="podcast_value_stream_hint">Sends value automatically while you listen.</string>
|
||||
<string name="podcast_value_streamed_total">Streamed %1$d sats this session</string>
|
||||
<string name="podcast_value_stream_requires_wallet">Connect a Nostr Wallet Connect or debit wallet to stream sats while listening.</string>
|
||||
<string name="podcast_new_episode">New episode</string>
|
||||
<string name="podcast_edit_episode">Edit episode</string>
|
||||
<string name="podcast_publishing_banner">Publishing…</string>
|
||||
<string name="podcast_cover_upload_cta">Add cover art</string>
|
||||
<string name="podcast_cover_upload_hint">Square image shown for the episode</string>
|
||||
<string name="podcast_episode_title_label">Title</string>
|
||||
<string name="podcast_episode_title_placeholder">Episode title</string>
|
||||
<string name="podcast_episode_summary_label">Summary</string>
|
||||
<string name="podcast_episode_duration_label">Duration (seconds)</string>
|
||||
<string name="podcast_episode_more_details">More details</string>
|
||||
<string name="podcast_episode_season_label">Season</string>
|
||||
<string name="podcast_episode_number_label">Episode #</string>
|
||||
<string name="podcast_episode_video_label">Video URL</string>
|
||||
<string name="podcast_episode_transcript_label">Transcript URL</string>
|
||||
<string name="podcast_episode_chapters_label">Chapters URL</string>
|
||||
<string name="podcast_episode_topics_label">Topics</string>
|
||||
<string name="podcast_episode_topics_placeholder">comma, separated, tags</string>
|
||||
<string name="podcast_episode_audio_url_label">Audio URL</string>
|
||||
<string name="podcast_episode_audio_url_placeholder">https://…/episode.mp3</string>
|
||||
<string name="podcast_episode_audio_picked">Audio file ready to upload</string>
|
||||
<string name="podcast_episode_audio_upload_cta">Add audio file</string>
|
||||
<string name="podcast_episode_audio_upload_hint">MP3, M4A, or other audio</string>
|
||||
<string name="podcast_episode_delete">Delete episode</string>
|
||||
<string name="podcast_episode_delete_confirm">Delete this episode? This can\'t be undone.</string>
|
||||
<string name="podcast_edit_show">Your podcast</string>
|
||||
<string name="podcast_show_cover_cta">Add cover art</string>
|
||||
<string name="podcast_show_cover_hint">Square artwork for your show</string>
|
||||
<string name="podcast_show_title_label">Show title</string>
|
||||
<string name="podcast_show_title_placeholder">My Podcast</string>
|
||||
<string name="podcast_show_description_label">Description</string>
|
||||
<string name="podcast_show_author_label">Author</string>
|
||||
<string name="podcast_show_email_label">Contact email</string>
|
||||
<string name="podcast_show_website_label">Website</string>
|
||||
<string name="podcast_show_categories_label">Categories</string>
|
||||
<string name="podcast_show_categories_placeholder">Technology, News</string>
|
||||
<string name="podcast_show_funding_label">Funding links</string>
|
||||
<string name="podcast_show_funding_placeholder">https://…</string>
|
||||
<string name="podcast_show_language_label">Language</string>
|
||||
<string name="podcast_show_language_placeholder">en</string>
|
||||
<string name="podcast_show_copyright_label">Copyright</string>
|
||||
<string name="podcast_show_type_label">Show type</string>
|
||||
<string name="podcast_show_type_episodic">Episodic</string>
|
||||
<string name="podcast_show_type_serial">Serial</string>
|
||||
<string name="podcast_show_explicit">Explicit content</string>
|
||||
<string name="podcast_show_complete">Show complete (no more episodes)</string>
|
||||
<string name="podcast_show_locked">Locked (premium)</string>
|
||||
<string name="podcast_new_trailer">New trailer</string>
|
||||
<string name="podcast_trailer_upload_cta">Add trailer clip</string>
|
||||
<string name="podcast_trailer_upload_hint">Short audio or video preview</string>
|
||||
<string name="podcast_trailer_title_placeholder">Trailer title</string>
|
||||
<string name="podcast_trailer_url_label">Media URL</string>
|
||||
<string name="podcast_your_podcast">Your podcast</string>
|
||||
<string name="podcast_untitled">Untitled</string>
|
||||
<string name="podcast_no_episodes_yet">No episodes yet. Tap New episode to publish your first one.</string>
|
||||
<string name="podcast_create_your_show">Create your podcast</string>
|
||||
<string name="podcast_tap_to_edit_show">Tap to edit show details</string>
|
||||
<string name="podcast_create_show_hint">Set up your show\'s title, art, and details</string>
|
||||
<plurals name="podcast_trailer_count">
|
||||
<item quantity="one">%1$d trailer</item>
|
||||
<item quantity="other">%1$d trailers</item>
|
||||
</plurals>
|
||||
<string name="podcast_value_editor_hint">Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations.</string>
|
||||
<string name="podcast_value_add_recipient">Add recipient</string>
|
||||
<string name="podcast_value_add_address">Add address manually</string>
|
||||
<string name="podcast_value_search_user">Add a Nostr user</string>
|
||||
<string name="podcast_value_search_user_hint">Search by name or @handle</string>
|
||||
<string name="podcast_value_user_no_lnaddress">This user has no Lightning address</string>
|
||||
<string name="podcast_value_remove_recipient">Remove recipient</string>
|
||||
<string name="podcast_value_recipient_name">Name (optional)</string>
|
||||
<string name="podcast_value_type_lnaddress">Lightning address</string>
|
||||
<string name="podcast_value_type_node">Node (keysend)</string>
|
||||
<string name="podcast_value_lnaddress">Lightning address</string>
|
||||
<string name="podcast_value_lnaddress_hint">name@example.com</string>
|
||||
<string name="podcast_value_node_pubkey">Node pubkey</string>
|
||||
<string name="podcast_value_node_pubkey_hint">02abc… (33-byte hex)</string>
|
||||
<string name="podcast_value_weight">Weight</string>
|
||||
<string name="podcast_value_fee">Fee</string>
|
||||
<plurals name="podcast_episode_count">
|
||||
<item quantity="one">%1$d episode</item>
|
||||
<item quantity="other">%1$d episodes</item>
|
||||
@@ -1025,6 +1137,8 @@
|
||||
<string name="public_bookmarks">Public Bookmarks</string>
|
||||
<string name="repository_bookmarks">Repositories</string>
|
||||
<string name="repository_bookmarks_explainer">Your bookmarked git repositories</string>
|
||||
<string name="podcast_bookmarks">Podcasts</string>
|
||||
<string name="podcast_bookmarks_explainer">Your bookmarked podcasts and episodes</string>
|
||||
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
|
||||
<string name="add_to_public_bookmarks">Add to Public Bookmarks</string>
|
||||
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class MergeFilterTagsTest {
|
||||
@Test
|
||||
fun `null base passes the extra through`() {
|
||||
assertEquals(mapOf("d" to listOf("podcast-metadata")), mergeFilterTags(null, PODCAST_METADATA_D_FILTER))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null extra passes the base through`() {
|
||||
val base = mapOf("t" to listOf("tech"))
|
||||
assertEquals(base, mergeFilterTags(base, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both null stays null`() {
|
||||
assertNull(mergeFilterTags(null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disjoint keys are unioned - layering d onto an existing t constraint`() {
|
||||
val merged = mergeFilterTags(mapOf("t" to listOf("tech")), PODCAST_METADATA_D_FILTER)
|
||||
assertEquals(
|
||||
mapOf("t" to listOf("tech"), "d" to listOf("podcast-metadata")),
|
||||
merged,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same key merges and de-duplicates values`() {
|
||||
val merged = mergeFilterTags(mapOf("d" to listOf("podcast-metadata")), mapOf("d" to listOf("podcast-metadata", "other")))
|
||||
assertEquals(mapOf("d" to listOf("podcast-metadata", "other")), merged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the d filter targets the podstr metadata kind and d-tag`() {
|
||||
assertEquals(listOf(AppSpecificDataEvent.KIND), PODCASTING20_METADATA_KINDS)
|
||||
assertEquals(mapOf("d" to listOf("podcast-metadata")), PODCAST_METADATA_D_FILTER)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.cli.commands.NotesCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.NsiteCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.OfferCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.OutboxCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands
|
||||
import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
|
||||
@@ -233,6 +234,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"serve" -> ServeCommand.run(dataDir, tail)
|
||||
"cashu" -> CashuCommands.dispatch(dataDir, tail)
|
||||
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
|
||||
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
|
||||
"bunker" -> BunkerCommand.run(dataDir, tail)
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
@@ -478,6 +480,21 @@ private fun printUsage() {
|
||||
| [--image URL] [--content MARKDOWN]
|
||||
| podcast list [USER] [--limit N] list a user's metadata + episodes
|
||||
|
|
||||
|Podcasts (Podcasting 2.0 / podstr):
|
||||
| podcast20 metadata --title T publish kind:30078 show metadata (JSON body)
|
||||
| [--description D] [--author A] [--image URL] [--language L]
|
||||
| [--categories A,B] [--funding URL,URL] [--website URL]
|
||||
| [--copyright C] [--type episodic|serial] [--explicit] [--complete]
|
||||
| [--value-json JSON] value-for-value split block
|
||||
| podcast20 episode --title T --audio URL[,URL] publish a kind:30054 episode
|
||||
| [--d ID] [--audio-type MIME] [--description D] [--image URL]
|
||||
| [--duration SECS] [--video URL] [--video-type MIME]
|
||||
| [--episode N] [--season N] [--transcript URL] [--chapters URL]
|
||||
| [--value-json JSON] [--topic A,B] [--content MARKDOWN] [--pubdate RFC2822]
|
||||
| podcast20 trailer --title T --url URL publish a kind:30055 trailer
|
||||
| [--d ID] [--type MIME] [--length BYTES] [--season N] [--pubdate RFC2822]
|
||||
| podcast20 list [USER] [--limit N] list a creator's metadata + episodes + trailers
|
||||
|
|
||||
|Static websites (NIP-5A kind:15128/35128):
|
||||
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
|
||||
| [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* `amy podcast20 <metadata|episode|trailer|list>` — the Podcasting-2.0 draft (derekross/podstr),
|
||||
* kept separate from the NIP-F4 `podcast` commands because the two models differ: here the
|
||||
* logged-in account IS the creator and signs everything with its own key, and episodes/trailers
|
||||
* are addressable (`d`-tag) events that can be edited in place.
|
||||
*
|
||||
* metadata publish kind:30078 show metadata (`d=podcast-metadata`, JSON body)
|
||||
* episode publish a kind:30054 episode
|
||||
* trailer publish a kind:30055 trailer
|
||||
* list list a creator's metadata + episodes + trailers
|
||||
*
|
||||
* Thin assembly only: events and JSON live in quartz (`Podcasting20EpisodeEvent`,
|
||||
* `Podcasting20TrailerEvent`, `Podcasting20PodcastMetadata`).
|
||||
*/
|
||||
object Podcast20Commands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"podcast20",
|
||||
tail,
|
||||
"podcast20 <metadata|episode|trailer|list>",
|
||||
mapOf(
|
||||
"metadata" to { rest -> metadata(dataDir, rest) },
|
||||
"episode" to { rest -> episode(dataDir, rest) },
|
||||
"trailer" to { rest -> trailer(dataDir, rest) },
|
||||
"list" to { rest -> list(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun metadata(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 metadata requires --title")
|
||||
val value =
|
||||
valueFlag(args).getOrElse {
|
||||
return Output.error("bad_args", "podcast20 metadata --value-json is not valid JSON")
|
||||
}
|
||||
|
||||
val content =
|
||||
Podcasting20PodcastMetadata.Content(
|
||||
title = title,
|
||||
description = args.flag("description"),
|
||||
author = args.flag("author"),
|
||||
email = args.flag("email"),
|
||||
image = args.flag("image"),
|
||||
language = args.flag("language"),
|
||||
categories = listFlag(args, "categories"),
|
||||
explicit = trueIfPresent(args, "explicit"),
|
||||
website = args.flag("website"),
|
||||
copyright = args.flag("copyright"),
|
||||
funding = listFlag(args, "funding"),
|
||||
locked = trueIfPresent(args, "locked"),
|
||||
type = args.flag("type"),
|
||||
complete = trueIfPresent(args, "complete"),
|
||||
guid = args.flag("guid"),
|
||||
value = value,
|
||||
)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val signed = ctx.signer.sign(Podcasting20PodcastMetadata.build(content))
|
||||
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"d" to Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG,
|
||||
"title" to title,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun episode(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 episode requires --title")
|
||||
val audioType = args.flag("audio-type")
|
||||
val audios =
|
||||
args
|
||||
.flag("audio")
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { PodcastAudio(it, audioType) }
|
||||
.orEmpty()
|
||||
if (audios.isEmpty()) return Output.error("bad_args", "podcast20 episode requires --audio URL[,URL…]")
|
||||
val value =
|
||||
valueFlag(args).getOrElse {
|
||||
return Output.error("bad_args", "podcast20 episode --value-json is not valid JSON")
|
||||
}
|
||||
|
||||
val dTag = args.flag("d") ?: generateDTag("episode")
|
||||
val video = args.flag("video")?.let { PodcastAudio(it, args.flag("video-type")) }
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val template =
|
||||
Podcasting20EpisodeEvent.build(
|
||||
dTag = dTag,
|
||||
title = title,
|
||||
audios = audios,
|
||||
pubdate = args.flag("pubdate") ?: rfc2822Now(),
|
||||
description = args.flag("description"),
|
||||
image = args.flag("image"),
|
||||
durationInSeconds = args.flag("duration")?.toLongOrNull(),
|
||||
video = video,
|
||||
episodeNumber = args.flag("episode")?.toIntOrNull(),
|
||||
season = args.flag("season")?.toIntOrNull(),
|
||||
transcriptUrl = args.flag("transcript"),
|
||||
chaptersUrl = args.flag("chapters"),
|
||||
value = value,
|
||||
topics = listFlag(args, "topic"),
|
||||
markdownContent = args.flag("content", "") ?: "",
|
||||
)
|
||||
val signed = ctx.signer.sign(template)
|
||||
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"d" to dTag,
|
||||
"title" to title,
|
||||
"audios" to audios.map { it.url },
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun trailer(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 trailer requires --title")
|
||||
val url = args.flag("url") ?: return Output.error("bad_args", "podcast20 trailer requires --url")
|
||||
val dTag = args.flag("d") ?: generateDTag("trailer")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val template =
|
||||
Podcasting20TrailerEvent.build(
|
||||
dTag = dTag,
|
||||
title = title,
|
||||
url = url,
|
||||
pubdate = args.flag("pubdate") ?: rfc2822Now(),
|
||||
lengthInBytes = args.flag("length")?.toLongOrNull(),
|
||||
mimeType = args.flag("type"),
|
||||
season = args.flag("season")?.toIntOrNull(),
|
||||
)
|
||||
val signed = ctx.signer.sign(template)
|
||||
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"d" to dTag,
|
||||
"title" to title,
|
||||
"url" to url,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
val received =
|
||||
ctx.drain(
|
||||
relays.associateWith {
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND, AppSpecificDataEvent.KIND),
|
||||
authors = listOf(author),
|
||||
limit = limit,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val events = received.map { it.second }.distinctBy { it.id }
|
||||
val show =
|
||||
events
|
||||
.filterIsInstance<AppSpecificDataEvent>()
|
||||
.mapNotNull { Podcasting20PodcastMetadata.parse(it) }
|
||||
.maxByOrNull { it.event.createdAt }
|
||||
val episodes =
|
||||
events
|
||||
.filterIsInstance<Podcasting20EpisodeEvent>()
|
||||
.sortedByDescending { it.createdAt }
|
||||
.map {
|
||||
mapOf(
|
||||
"event_id" to it.id,
|
||||
"d" to it.dTag(),
|
||||
"title" to it.title(),
|
||||
"season" to it.season(),
|
||||
"episode" to it.number(),
|
||||
"audios" to it.audios().map { a -> a.url },
|
||||
"created_at" to it.createdAt,
|
||||
)
|
||||
}
|
||||
val trailers =
|
||||
events
|
||||
.filterIsInstance<Podcasting20TrailerEvent>()
|
||||
.sortedByDescending { it.createdAt }
|
||||
.map {
|
||||
mapOf(
|
||||
"event_id" to it.id,
|
||||
"d" to it.dTag(),
|
||||
"title" to it.title(),
|
||||
"url" to it.url(),
|
||||
"season" to it.season(),
|
||||
"created_at" to it.createdAt,
|
||||
)
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"pubkey" to author,
|
||||
"metadata" to
|
||||
show?.let {
|
||||
mapOf(
|
||||
"title" to it.showTitle(),
|
||||
"description" to it.showDescription(),
|
||||
"image" to it.showImage(),
|
||||
"author" to it.showAuthor(),
|
||||
"categories" to it.showCategories(),
|
||||
"funding" to it.showFundingUrls(),
|
||||
)
|
||||
},
|
||||
"episode_count" to episodes.size,
|
||||
"episodes" to episodes,
|
||||
"trailer_count" to trailers.size,
|
||||
"trailers" to trailers,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun listFlag(
|
||||
args: Args,
|
||||
name: String,
|
||||
): List<String> =
|
||||
args
|
||||
.flag(name)
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
/** A boolean flag maps to `true` when present and `null` when absent, so it's omitted from the JSON. */
|
||||
private fun trueIfPresent(
|
||||
args: Args,
|
||||
name: String,
|
||||
): Boolean? = if (args.bool(name)) true else null
|
||||
|
||||
/**
|
||||
* Parses the `--value-json` value-for-value block. Success with null means the flag was absent;
|
||||
* a failure means it was present but malformed (the caller turns that into a bad_args error).
|
||||
*/
|
||||
private fun valueFlag(args: Args): Result<PodcastValue?> {
|
||||
val json = args.flag("value-json") ?: return Result.success(null)
|
||||
return runCatching { JsonMapper.fromJson<PodcastValue>(json) }
|
||||
}
|
||||
|
||||
private fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}"
|
||||
|
||||
/** Current time as an RFC2822 date string (e.g. `Tue, 24 Jun 2025 12:00:00 GMT`), as the spec's `pubdate` expects. */
|
||||
private fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")))
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.podcasts
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient
|
||||
|
||||
/**
|
||||
* Editable state for a Podcasting-2.0 value-for-value (V4V) split, shared by the show and episode
|
||||
* composers. Holds one [RecipientDraft] per payee; [toPodcastValue] turns the drafts back into a
|
||||
* [PodcastValue] on save (or null when there are no payable recipients). The suggested amount /
|
||||
* currency / enabled flag on a loaded block are carried through untouched — the editor only manages
|
||||
* the recipient list.
|
||||
*
|
||||
* Lives in `commons` (a CLI-safe snapshot-state holder, no Compose UI) so any front end can drive a
|
||||
* V4V split editor; the actual editor composable is platform-side.
|
||||
*/
|
||||
class V4VSplitEditorState {
|
||||
val recipients = mutableStateListOf<RecipientDraft>()
|
||||
|
||||
private var amount: Long? = null
|
||||
private var currency: String? = null
|
||||
private var enabled: Boolean? = null
|
||||
|
||||
fun load(value: PodcastValue?) {
|
||||
recipients.clear()
|
||||
amount = value?.amount
|
||||
currency = value?.currency
|
||||
enabled = value?.enabled
|
||||
value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) }
|
||||
}
|
||||
|
||||
/** Add a blank row for a raw destination (a node keysend, or a non-Nostr lightning address). */
|
||||
fun addManual() {
|
||||
recipients.add(RecipientDraft())
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Nostr user as a recipient. Returns false (and adds nothing) if the user has no lightning
|
||||
* address to pay — there's nothing to put in the value block. Duplicate users are ignored.
|
||||
*/
|
||||
fun addUser(user: User): Boolean {
|
||||
if (user.lnAddress().isNullOrBlank()) return false
|
||||
if (recipients.any { it.user.value?.pubkeyHex == user.pubkeyHex }) return true
|
||||
recipients.add(RecipientDraft.forUser(user))
|
||||
return true
|
||||
}
|
||||
|
||||
fun remove(draft: RecipientDraft) {
|
||||
recipients.remove(draft)
|
||||
}
|
||||
|
||||
/** Sum of the weights of the payable recipients — used to show each as a percentage. */
|
||||
fun totalSplit(): Int = recipients.mapNotNull { it.toRecipient() }.sumOf { it.split }
|
||||
|
||||
fun toPodcastValue(): PodcastValue? {
|
||||
val valid = recipients.mapNotNull { it.toRecipient() }
|
||||
if (valid.isEmpty()) return null
|
||||
return PodcastValue(
|
||||
enabled = enabled,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
recipients = valid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable recipient row. Either backed by a Nostr [user] (rendered with avatar + name; its
|
||||
* lightning address resolves at save time) or a raw destination typed by hand ([name]/[isNode]/
|
||||
* [address]). All fields are Compose state so the editor recomposes as they change.
|
||||
*/
|
||||
class RecipientDraft {
|
||||
/** When set, this row is a Nostr user — paid at their lud16, shown with avatar + name. */
|
||||
val user = mutableStateOf<User?>(null)
|
||||
|
||||
val name = mutableStateOf("")
|
||||
|
||||
/** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */
|
||||
val isNode = mutableStateOf(false)
|
||||
val address = mutableStateOf("")
|
||||
val split = mutableStateOf("1")
|
||||
val fee = mutableStateOf(false)
|
||||
|
||||
/** A recipient is payable once it resolves to an address and has a positive weight. */
|
||||
fun toRecipient(): PodcastValueRecipient? {
|
||||
val weight = split.value.trim().toIntOrNull() ?: return null
|
||||
if (weight <= 0) return null
|
||||
|
||||
user.value?.let { u ->
|
||||
val lud = u.lnAddress()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return PodcastValueRecipient(
|
||||
name = u.toBestDisplayName(),
|
||||
type = PodcastValue.TYPE_LNADDRESS,
|
||||
address = lud,
|
||||
split = weight,
|
||||
fee = if (fee.value) true else null,
|
||||
)
|
||||
}
|
||||
|
||||
val addr = address.value.trim()
|
||||
if (addr.isBlank()) return null
|
||||
return PodcastValueRecipient(
|
||||
name = name.value.trim().ifBlank { null },
|
||||
type = if (isNode.value) PodcastValue.TYPE_NODE else PodcastValue.TYPE_LNADDRESS,
|
||||
address = addr,
|
||||
split = weight,
|
||||
fee = if (fee.value) true else null,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun forUser(user: User): RecipientDraft =
|
||||
RecipientDraft().apply {
|
||||
this.user.value = user
|
||||
}
|
||||
|
||||
fun from(recipient: PodcastValueRecipient): RecipientDraft =
|
||||
RecipientDraft().apply {
|
||||
name.value = recipient.name.orEmpty()
|
||||
isNode.value = recipient.type == PodcastValue.TYPE_NODE
|
||||
address.value = recipient.address.orEmpty()
|
||||
split.value = recipient.split.toString()
|
||||
fee.value = recipient.fee == true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
/**
|
||||
* Human-readable label and defining NIP for a Nostr event kind.
|
||||
@@ -337,6 +339,8 @@ object KindNames {
|
||||
PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"),
|
||||
AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"),
|
||||
FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"),
|
||||
Podcasting20EpisodeEvent.KIND to KindName("Podcast Episode (Podcasting 2.0)", null),
|
||||
Podcasting20TrailerEvent.KIND to KindName("Podcast Trailer (Podcasting 2.0)", null),
|
||||
AttestationEvent.KIND to KindName("Attestation", null),
|
||||
AttestationRequestEvent.KIND to KindName("Attestation Request", null),
|
||||
AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null),
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
|
||||
+23
@@ -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)
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ChaptersTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EpisodeNumberTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ValueTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastPerson
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 draft podcast episode (`kind:30054`), as published by clients like
|
||||
* derekross/podstr. Unlike NIP-F4 (where the podcast is its own keypair and episodes
|
||||
* are regular `kind:54` events), here the **human creator** is the keypair and each
|
||||
* episode is an *addressable* event keyed by its `d` tag, so it can be edited in place.
|
||||
*
|
||||
* Implements the spec-neutral [PodcastEpisode] so it lands in the same merged
|
||||
* episode list as NIP-F4 episodes.
|
||||
*/
|
||||
@Immutable
|
||||
class Podcasting20EpisodeEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
PodcastEpisode,
|
||||
RootScope,
|
||||
SearchableEvent {
|
||||
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
|
||||
|
||||
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
|
||||
|
||||
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
|
||||
|
||||
fun audios() = tags.mapNotNull(AudioTag::parse)
|
||||
|
||||
fun video() = tags.firstNotNullOfOrNull(VideoTag::parse)
|
||||
|
||||
fun number() = tags.firstNotNullOfOrNull(EpisodeNumberTag::parse)
|
||||
|
||||
fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse)
|
||||
|
||||
fun transcriptUrl() = tags.firstNotNullOfOrNull(TranscriptTag::parse)
|
||||
|
||||
fun chaptersUrl() = tags.firstNotNullOfOrNull(ChaptersTag::parse)
|
||||
|
||||
fun value() = tags.firstNotNullOfOrNull(ValueTag::parse)
|
||||
|
||||
fun persons() = tags.mapNotNull(PersonTag::parse)
|
||||
|
||||
fun soundbites() = tags.mapNotNull(SoundbiteTag::parse)
|
||||
|
||||
fun durationInSeconds() = tags.firstNotNullOfOrNull(DurationTag::parse)
|
||||
|
||||
/** RFC2822 publication date string, kept verbatim for RSS generation. */
|
||||
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
|
||||
|
||||
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
|
||||
|
||||
fun topics() = hashtags()
|
||||
|
||||
/** Event id of the original publication when this is an edit, if present. */
|
||||
fun editsEventId() = tags.firstNotNullOfOrNull(EditTag::parse)
|
||||
|
||||
override fun episodeTitle() = title()
|
||||
|
||||
override fun episodeImage() = image()
|
||||
|
||||
override fun episodeDescription() = description()
|
||||
|
||||
override fun episodeAudio() = audios()
|
||||
|
||||
override fun episodeDurationInSeconds() = durationInSeconds()
|
||||
|
||||
override fun episodePublishedAt() = createdAt
|
||||
|
||||
override fun episodeVideo() = video()
|
||||
|
||||
override fun episodeNumber() = number()
|
||||
|
||||
override fun episodeSeason() = season()
|
||||
|
||||
override fun episodeTranscriptUrl() = transcriptUrl()
|
||||
|
||||
override fun episodeChaptersUrl() = chaptersUrl()
|
||||
|
||||
override fun episodeValue() = value()
|
||||
|
||||
override fun episodePersons() = persons()
|
||||
|
||||
override fun episodeSoundbites() = soundbites()
|
||||
|
||||
companion object {
|
||||
const val KIND = 30054
|
||||
|
||||
fun build(
|
||||
dTag: String,
|
||||
title: String,
|
||||
audios: List<PodcastAudio>,
|
||||
pubdate: String,
|
||||
alt: String = "Podcast episode: $title",
|
||||
description: String? = null,
|
||||
image: String? = null,
|
||||
durationInSeconds: Long? = null,
|
||||
video: PodcastAudio? = null,
|
||||
episodeNumber: Int? = null,
|
||||
season: Int? = null,
|
||||
transcriptUrl: String? = null,
|
||||
chaptersUrl: String? = null,
|
||||
value: PodcastValue? = null,
|
||||
persons: List<PodcastPerson> = emptyList(),
|
||||
soundbites: List<PodcastSoundbite> = emptyList(),
|
||||
topics: List<String> = emptyList(),
|
||||
markdownContent: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Podcasting20EpisodeEvent>.() -> Unit = {},
|
||||
) = eventTemplate<Podcasting20EpisodeEvent>(KIND, markdownContent, createdAt) {
|
||||
dTag(dTag)
|
||||
title(title)
|
||||
audios.forEach { audio(it) }
|
||||
pubdate(pubdate)
|
||||
alt(alt)
|
||||
|
||||
description?.let { description(it) }
|
||||
image?.let { image(it) }
|
||||
durationInSeconds?.let { duration(it) }
|
||||
video?.let { video(it) }
|
||||
episodeNumber?.let { episodeNumber(it) }
|
||||
season?.let { season(it) }
|
||||
transcriptUrl?.let { transcript(it) }
|
||||
chaptersUrl?.let { chapters(it) }
|
||||
value?.let { value(it) }
|
||||
persons.filter { it.isValid() }.forEach { person(it) }
|
||||
soundbites.forEach { soundbite(it) }
|
||||
if (topics.isNotEmpty()) hashtags(topics)
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ChaptersTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EpisodeNumberTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ValueTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastPerson
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.video(video: PodcastAudio) = addUnique(VideoTag.assemble(video))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.episodeNumber(number: Int) = addUnique(EpisodeNumberTag.assemble(number))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.season(season: Int) = addUnique(SeasonTag.assemble(season))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.transcript(url: String) = addUnique(TranscriptTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.chapters(url: String) = addUnique(ChaptersTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.value(value: PodcastValue) = addUnique(ValueTag.assemble(value))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.person(person: PodcastPerson) = add(PersonTag.assemble(person))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.soundbite(soundbite: PodcastSoundbite) = add(SoundbiteTag.assemble(soundbite))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 episode audio tag: `["audio", "<url>", "<optional_media_type>"]`.
|
||||
*
|
||||
* Wire-identical to the NIP-F4 audio tag, so it parses straight into the shared
|
||||
* [PodcastAudio] holder that the unified [com.vitorpamplona.quartz.podcasts.PodcastEpisode]
|
||||
* abstraction exposes.
|
||||
*/
|
||||
class AudioTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "audio"
|
||||
|
||||
fun parse(tag: Array<String>): PodcastAudio? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
|
||||
return PodcastAudio(tag[1], mediaType)
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
url: String,
|
||||
mediaType: String? = null,
|
||||
): Array<String> =
|
||||
if (mediaType.isNullOrEmpty()) {
|
||||
arrayOf(TAG_NAME, url)
|
||||
} else {
|
||||
arrayOf(TAG_NAME, url, mediaType)
|
||||
}
|
||||
|
||||
fun assemble(audio: PodcastAudio) = assemble(audio.url, audio.mediaType)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 episode chapters file URL: `["chapters", "<url>"]`. Points at a Podcasting-2.0
|
||||
* JSON chapters document (timestamped chapter list) hosted off-event.
|
||||
*/
|
||||
class ChaptersTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "chapters"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(url: String) = arrayOf(TAG_NAME, url)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class DescriptionTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "description"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(description: String) = arrayOf(TAG_NAME, description)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 episode duration in whole seconds: `["duration", "3600"]`. */
|
||||
class DurationTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "duration"
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(durationInSeconds: Long) = arrayOf(TAG_NAME, durationInSeconds.toString())
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 edit-history pointer: `["edit", "<original-event-id>"]`. References
|
||||
* the event id of the original publication when an addressable episode/trailer is
|
||||
* updated, so clients can reconstruct edit history.
|
||||
*/
|
||||
class EditTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "edit"
|
||||
|
||||
fun parse(tag: Array<String>): HexKey? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(originalEventId: HexKey) = arrayOf(TAG_NAME, originalEventId)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 episode number within its season/show: `["episode", "5"]`. */
|
||||
class EpisodeNumberTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "episode"
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toIntOrNull()
|
||||
}
|
||||
|
||||
fun assemble(episodeNumber: Int) = arrayOf(TAG_NAME, episodeNumber.toString())
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class ImageTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "image"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(url: String) = arrayOf(TAG_NAME, url)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastPerson
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 episode person credit: `["person", "<name>", "<role>", "<img>", "<href>"]`.
|
||||
*
|
||||
* Only the name (slot 1) is required; role/img/href are optional and carried positionally, with
|
||||
* empty strings standing in for absent middle values. Maps to the shared [PodcastPerson] holder.
|
||||
* (The show-level `podcast:person` `group` attribute isn't carried on the episode tag — it's
|
||||
* organizational metadata that lives in the show's JSON when present.)
|
||||
*/
|
||||
class PersonTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "person"
|
||||
|
||||
fun parse(tag: Array<String>): PodcastPerson? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return PodcastPerson(
|
||||
name = tag[1],
|
||||
role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() },
|
||||
img = tag.getOrNull(3)?.takeIf { it.isNotEmpty() },
|
||||
href = tag.getOrNull(4)?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
fun assemble(person: PodcastPerson): Array<String> {
|
||||
// Trim trailing empties so a person with only a name is a 2-element tag, but keep empty
|
||||
// placeholders in the middle so href stays in slot 4 when role/img are missing.
|
||||
val slots = listOf(person.name, person.role ?: "", person.img ?: "", person.href ?: "")
|
||||
val lastNonEmpty = slots.indexOfLast { it.isNotEmpty() }.coerceAtLeast(0)
|
||||
return (listOf(TAG_NAME) + slots.subList(0, lastNonEmpty + 1)).toTypedArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 publication date in RFC2822 format, set once when first published
|
||||
* and preserved across edits: `["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"]`.
|
||||
*
|
||||
* The string is kept verbatim — it feeds RSS generation, where the exact RFC2822
|
||||
* spelling matters. Feed ordering relies on the event's `created_at` instead, so
|
||||
* no date parsing is required here.
|
||||
*/
|
||||
class PubDateTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "pubdate"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(rfc2822Date: String) = arrayOf(TAG_NAME, rfc2822Date)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 season number an episode belongs to: `["season", "2"]`. */
|
||||
class SeasonTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "season"
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toIntOrNull()
|
||||
}
|
||||
|
||||
fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString())
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 episode soundbite: `["soundbite", "<startTime>", "<duration>", "<optional title>"]`.
|
||||
*
|
||||
* `startTime` and `duration` are in seconds (may be fractional). Title is optional. Maps to the
|
||||
* shared [PodcastSoundbite] holder. A soundbite with a non-positive duration is dropped as invalid.
|
||||
*/
|
||||
class SoundbiteTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "soundbite"
|
||||
|
||||
fun parse(tag: Array<String>): PodcastSoundbite? {
|
||||
ensure(tag.has(2)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
val start = tag[1].toDoubleOrNull() ?: return null
|
||||
val duration = tag[2].toDoubleOrNull() ?: return null
|
||||
ensure(start >= 0.0) { return null }
|
||||
ensure(duration > 0.0) { return null }
|
||||
return PodcastSoundbite(start, duration, tag.getOrNull(3)?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
fun assemble(soundbite: PodcastSoundbite): Array<String> {
|
||||
val head = arrayOf(TAG_NAME, soundbite.startTimeSeconds.toString(), soundbite.durationSeconds.toString())
|
||||
val title = soundbite.title
|
||||
return if (title.isNullOrEmpty()) head else head + title
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class TitleTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "title"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(title: String) = arrayOf(TAG_NAME, title)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 episode transcript file URL: `["transcript", "<url>"]`. */
|
||||
class TranscriptTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "transcript"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(url: String) = arrayOf(TAG_NAME, url)
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 episode value-for-value tag: `["value", "<json>"]`, where the value is a JSON
|
||||
* [PodcastValue] object (with `enabled` for episode-level overrides). Parses leniently — a malformed
|
||||
* body yields null rather than throwing.
|
||||
*/
|
||||
class ValueTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "value"
|
||||
|
||||
fun parse(tag: Array<String>): PodcastValue? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return runCatching { JsonMapper.fromJson<PodcastValue>(tag[1]) }.getOrNull()
|
||||
}
|
||||
|
||||
fun assemble(value: PodcastValue) = arrayOf(TAG_NAME, JsonMapper.toJson(value))
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 episode video tag: `["video", "<url>", "<optional_media_type>"]`. The same wire
|
||||
* shape as the audio tag; an episode MAY ship a video alongside (or instead of) its audio. Parses
|
||||
* into the shared [PodcastAudio] media holder so a client can play it through the same path.
|
||||
*/
|
||||
class VideoTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "video"
|
||||
|
||||
fun parse(tag: Array<String>): PodcastAudio? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
|
||||
return PodcastAudio(tag[1], mediaType)
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
url: String,
|
||||
mediaType: String? = null,
|
||||
): Array<String> =
|
||||
if (mediaType.isNullOrEmpty()) {
|
||||
arrayOf(TAG_NAME, url)
|
||||
} else {
|
||||
arrayOf(TAG_NAME, url, mediaType)
|
||||
}
|
||||
|
||||
fun assemble(video: PodcastAudio) = assemble(video.url, video.mediaType)
|
||||
}
|
||||
}
|
||||
+60
@@ -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
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.metadata
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastPerson
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastShow
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastValue
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The Podcasting-2.0 draft stores show-level metadata in a `kind:30078` (NIP-78 app-data) event
|
||||
* with `d="podcast-metadata"`, where the channel fields live as a JSON object in `content` rather
|
||||
* than in tags. This is a parsed, read-only view over such an event that adapts it to the
|
||||
* spec-neutral [PodcastShow], so a podstr show merges into the same list and card as a NIP-F4
|
||||
* [com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent].
|
||||
*
|
||||
* Note: `kind:30078` is heavily overloaded across NIPs and clients; only events whose `d` tag is
|
||||
* exactly [PODCAST_METADATA_D_TAG] and whose content is valid podcast-metadata JSON resolve here.
|
||||
*/
|
||||
@Immutable
|
||||
class Podcasting20PodcastMetadata(
|
||||
val event: AppSpecificDataEvent,
|
||||
private val content: Content,
|
||||
) : PodcastShow {
|
||||
override fun showTitle() = content.title
|
||||
|
||||
override fun showImage() = content.image
|
||||
|
||||
override fun showDescription() = content.description
|
||||
|
||||
override fun showWebsites() = listOfNotNull(content.website?.takeIf { it.isNotEmpty() })
|
||||
|
||||
override fun showAuthor() = content.author?.takeIf { it.isNotEmpty() }
|
||||
|
||||
override fun showCategories() = content.categories.filter { it.isNotEmpty() }
|
||||
|
||||
override fun showFundingUrls() = content.funding.filter { it.isNotEmpty() }
|
||||
|
||||
override fun showIsExplicit() = content.explicit ?: false
|
||||
|
||||
override fun showIsComplete() = content.complete ?: false
|
||||
|
||||
override fun showCopyright() = content.copyright?.takeIf { it.isNotEmpty() }
|
||||
|
||||
override fun showValue() = content.value
|
||||
|
||||
override fun showPersons() = content.persons.filter { it.isValid() }
|
||||
|
||||
fun language() = content.language
|
||||
|
||||
/** Contact email for the show, if provided. */
|
||||
fun email() = content.email?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** "episodic" or "serial" per Podcasting 2.0, if provided. */
|
||||
fun type() = content.type?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** Whether the show is locked (premium / subscription-gated). */
|
||||
fun isLocked() = content.locked ?: false
|
||||
|
||||
/** The stable podcast GUID (Podcasting 2.0 `podcast:guid`), if provided. */
|
||||
fun guid() = content.guid?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* The subset of the Podcasting-2.0 `kind:30078` metadata JSON this client reads. Unknown keys
|
||||
* (notably `value` for value-for-value splits) are ignored by the lenient mapper and can be
|
||||
* surfaced later without changing the wire format.
|
||||
*/
|
||||
@Serializable
|
||||
class Content(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val author: String? = null,
|
||||
val email: String? = null,
|
||||
val image: String? = null,
|
||||
val language: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val explicit: Boolean? = null,
|
||||
val website: String? = null,
|
||||
val copyright: String? = null,
|
||||
val funding: List<String> = emptyList(),
|
||||
val locked: Boolean? = null,
|
||||
val type: String? = null,
|
||||
val complete: Boolean? = null,
|
||||
val guid: String? = null,
|
||||
val value: PodcastValue? = null,
|
||||
val persons: List<PodcastPerson> = emptyList(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val PODCAST_METADATA_D_TAG = "podcast-metadata"
|
||||
|
||||
/**
|
||||
* Returns a view if [event] is a podcast-metadata app-data event with parseable JSON,
|
||||
* otherwise null (wrong `d` tag, or non-JSON/encrypted content such as a user's own
|
||||
* app settings).
|
||||
*/
|
||||
fun parse(event: AppSpecificDataEvent): Podcasting20PodcastMetadata? {
|
||||
if (event.dTag() != PODCAST_METADATA_D_TAG) return null
|
||||
val content = runCatching { JsonMapper.fromJson<Content>(event.content) }.getOrNull() ?: return null
|
||||
return Podcasting20PodcastMetadata(event, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the kind:30078 show-metadata event template (`d="podcast-metadata"`) by serializing
|
||||
* [content] to its JSON body. Unset/default fields are omitted, keeping the payload minimal.
|
||||
*/
|
||||
fun build(
|
||||
content: Content,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventTemplate<AppSpecificDataEvent> = AppSpecificDataEvent.build(PODCAST_METADATA_D_TAG, JsonMapper.toJson(content), createdAt)
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 draft podcast trailer (`kind:30055`), following the Podcast 2.0
|
||||
* `<podcast:trailer>` element. Addressable like the episode (`kind:30054`) and
|
||||
* signed by the human creator's keypair, keyed by its `d` tag.
|
||||
*/
|
||||
@Immutable
|
||||
class Podcasting20TrailerEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
RootScope {
|
||||
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun url() = tags.firstNotNullOfOrNull(UrlTag::parse)
|
||||
|
||||
/** RFC2822 publication date string, kept verbatim for RSS generation. */
|
||||
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
|
||||
|
||||
fun lengthInBytes() = tags.firstNotNullOfOrNull(LengthTag::parse)
|
||||
|
||||
fun mimeType() = tags.firstNotNullOfOrNull(TypeTag::parse)
|
||||
|
||||
fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse)
|
||||
|
||||
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
|
||||
|
||||
companion object {
|
||||
const val KIND = 30055
|
||||
|
||||
fun build(
|
||||
dTag: String,
|
||||
title: String,
|
||||
url: String,
|
||||
pubdate: String,
|
||||
alt: String = "Podcast trailer: $title",
|
||||
lengthInBytes: Long? = null,
|
||||
mimeType: String? = null,
|
||||
season: Int? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Podcasting20TrailerEvent>.() -> Unit = {},
|
||||
) = eventTemplate<Podcasting20TrailerEvent>(KIND, title, createdAt) {
|
||||
dTag(dTag)
|
||||
title(title)
|
||||
url(url)
|
||||
pubdate(pubdate)
|
||||
alt(alt)
|
||||
|
||||
lengthInBytes?.let { length(it) }
|
||||
mimeType?.let { type(it) }
|
||||
season?.let { season(it) }
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.url(url: String) = addUnique(UrlTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.length(lengthInBytes: Long) = addUnique(LengthTag.assemble(lengthInBytes))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.type(mimeType: String) = addUnique(TypeTag.assemble(mimeType))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.season(season: Int) = addUnique(SeasonTag.assemble(season))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 trailer file size in bytes: `["length", "1024000"]`. */
|
||||
class LengthTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "length"
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(lengthInBytes: Long) = arrayOf(TAG_NAME, lengthInBytes.toString())
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 season number a trailer represents: `["season", "2"]`. */
|
||||
class SeasonTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "season"
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toIntOrNull()
|
||||
}
|
||||
|
||||
fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString())
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 trailer MIME type: `["type", "audio/mpeg"]`. */
|
||||
class TypeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "type"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 trailer media URL: `["url", "<media-url>"]`. */
|
||||
class UrlTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "url"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(url: String) = arrayOf(TAG_NAME, url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Spec-neutral media reference (a URL plus optional MIME type) for a podcast episode, used by the
|
||||
* shared [PodcastEpisode] abstraction so a UI can play episodes regardless of which podcast NIP
|
||||
* produced them. Despite the name it covers both audio and video sources.
|
||||
*
|
||||
* Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio in identical
|
||||
* `["audio", "<url>", "<optional_media_type>"]` tags; the Podcasting-2.0 draft uses the same shape
|
||||
* for its `video` tag. Each event maps its own tag class into this holder.
|
||||
*/
|
||||
@Immutable
|
||||
class PodcastAudio(
|
||||
val url: String,
|
||||
val mediaType: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The Podcasting-2.0 keysend metadata blob ("boostagram") carried in TLV record
|
||||
* [PodcastValue.PODCAST_TLV_RECORD] (7629169). It tells the receiving node which podcast/episode the
|
||||
* payment is for and how much was sent in total. Field names follow the satoshis.stream convention
|
||||
* (<https://github.com/satoshisstream/satoshis.stream/blob/main/TLV_registry.md>).
|
||||
*
|
||||
* Unset fields are omitted from the JSON ([JsonMapper] does not encode defaults), keeping the record
|
||||
* small enough to fit comfortably inside a keysend onion.
|
||||
*/
|
||||
@Serializable
|
||||
class PodcastBoostagram(
|
||||
val podcast: String? = null,
|
||||
val episode: String? = null,
|
||||
/** "stream" for per-minute streaming sats, "boost" for a deliberate lump-sum tip. */
|
||||
val action: String? = null,
|
||||
@SerialName("app_name")
|
||||
val appName: String? = null,
|
||||
/** Total sats (not millisats) the listener sent across all splits. */
|
||||
@SerialName("value_msat_total")
|
||||
val valueMsatTotal: Long? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("sender_name")
|
||||
val senderName: String? = null,
|
||||
) {
|
||||
fun toJson(): String = JsonMapper.toJson(this)
|
||||
|
||||
companion object {
|
||||
const val ACTION_STREAM = "stream"
|
||||
const val ACTION_BOOST = "boost"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The Podcasting-2.0 chapters document referenced by an episode's `chapters` tag — a JSON file
|
||||
* (per the podcast-namespace `chapters.json` spec) hosted off-event. Episodes carry only the URL;
|
||||
* a client fetches and parses it into this model to render a tappable chapter list.
|
||||
*/
|
||||
@Immutable
|
||||
@Serializable
|
||||
class PodcastChapters(
|
||||
val version: String? = null,
|
||||
val chapters: List<PodcastChapter> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
/** Lenient parse of a chapters.json body; returns null on malformed input. */
|
||||
fun parse(json: String): PodcastChapters? = runCatching { JsonMapper.fromJson<PodcastChapters>(json) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** One chapter marker. [startTime] is in seconds (may be fractional per the spec). */
|
||||
@Immutable
|
||||
@Serializable
|
||||
class PodcastChapter(
|
||||
val startTime: Double = 0.0,
|
||||
val title: String? = null,
|
||||
/** Chapter artwork URL (the spec field is `img`). */
|
||||
val img: String? = null,
|
||||
/** A related link for the chapter. */
|
||||
val url: String? = null,
|
||||
/** Whether the chapter should appear in a table of contents; absent means yes. */
|
||||
val toc: Boolean? = null,
|
||||
) {
|
||||
/** Whole-second start used for seeking/labeling. */
|
||||
fun startSeconds(): Long = startTime.toLong()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
/**
|
||||
* Spec-neutral view of a single podcast episode.
|
||||
*
|
||||
* Two competing podcast drafts publish episodes with incompatible identity models
|
||||
* and event kinds:
|
||||
* - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent],
|
||||
* `kind:54`): a regular event where the *podcast itself* is a Nostr keypair.
|
||||
* - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent],
|
||||
* `kind:30054`): an addressable event where the *human creator* is the keypair
|
||||
* and episodes are editable via their `d` tag.
|
||||
*
|
||||
* The two cannot share a single wire kind, but a client can still render them in
|
||||
* one list. This interface is that merge point: feeds and UI depend on it instead
|
||||
* of a concrete event, so both kinds flow into the same podcast/episode list.
|
||||
*/
|
||||
interface PodcastEpisode {
|
||||
/** The episode title shown in listings. */
|
||||
fun episodeTitle(): String?
|
||||
|
||||
/** Cover/episode artwork URL, if any. */
|
||||
fun episodeImage(): String?
|
||||
|
||||
/** Short, plain-text episode description/summary, if any. */
|
||||
fun episodeDescription(): String?
|
||||
|
||||
/**
|
||||
* One or more audio sources for the episode. Multiple entries typically offer
|
||||
* the same audio in different containers/codecs (e.g. mp3 + opus).
|
||||
*/
|
||||
fun episodeAudio(): List<PodcastAudio>
|
||||
|
||||
/** Episode duration in seconds, if the publisher provided it. */
|
||||
fun episodeDurationInSeconds(): Long?
|
||||
|
||||
/**
|
||||
* Unix timestamp (seconds) used to order episodes in a merged feed. Both drafts
|
||||
* fall back to the event's `created_at`; the Podcasting-2.0 draft additionally
|
||||
* carries an RFC2822 `pubdate` tag exposed by its own event class.
|
||||
*/
|
||||
fun episodePublishedAt(): Long
|
||||
|
||||
/**
|
||||
* A video source for the episode, if it ships one. NIP-F4 has no video tag and returns null;
|
||||
* the Podcasting-2.0 draft carries a `video` tag.
|
||||
*/
|
||||
fun episodeVideo(): PodcastAudio? = null
|
||||
|
||||
/** Episode number within the show/season, if provided. */
|
||||
fun episodeNumber(): Int? = null
|
||||
|
||||
/** Season number the episode belongs to, if provided. */
|
||||
fun episodeSeason(): Int? = null
|
||||
|
||||
/** URL of an off-event transcript document, if provided. */
|
||||
fun episodeTranscriptUrl(): String? = null
|
||||
|
||||
/** URL of an off-event Podcasting-2.0 chapters document, if provided. */
|
||||
fun episodeChaptersUrl(): String? = null
|
||||
|
||||
/**
|
||||
* The episode's value-for-value split block, if it overrides the show default. NIP-F4 has no
|
||||
* V4V and returns null.
|
||||
*/
|
||||
fun episodeValue(): PodcastValue? = null
|
||||
|
||||
/**
|
||||
* Hosts/guests credited on this specific episode (Podcasting-2.0 `podcast:person`). Empty when
|
||||
* the publisher lists no per-episode people (NIP-F4 has no person tag and returns empty).
|
||||
*/
|
||||
fun episodePersons(): List<PodcastPerson> = emptyList()
|
||||
|
||||
/**
|
||||
* Highlight clips of the episode (Podcasting-2.0 `podcast:soundbite`), each a start offset +
|
||||
* duration into the audio. Empty when none are declared.
|
||||
*/
|
||||
fun episodeSoundbites(): List<PodcastSoundbite> = emptyList()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A Podcasting-2.0 `podcast:person` — a host, guest, or other contributor credited on a show or a
|
||||
* single episode. Unlike a NIP-F4 author (a Nostr pubkey), a person is free-text: a [name] plus an
|
||||
* optional [role]/[group], an avatar [img] URL, and a [href] link to their page. None of these need
|
||||
* to be a Nostr identity, so it maps RSS `<podcast:person>` credits straight through.
|
||||
*
|
||||
* Carried two ways: show-level persons live in the `kind:30078` metadata JSON (a `persons` array),
|
||||
* and episode-level persons are `["person", ...]` tags on the `kind:30054` event.
|
||||
*/
|
||||
@Immutable
|
||||
@Serializable
|
||||
class PodcastPerson(
|
||||
val name: String = "",
|
||||
/** e.g. "host", "guest", "cohost" — free text per the Podcasting 2.0 taxonomy. */
|
||||
val role: String? = null,
|
||||
/** Grouping such as "cast" or "writing"; rarely displayed, kept for round-trip fidelity. */
|
||||
val group: String? = null,
|
||||
/** Avatar image URL. */
|
||||
val img: String? = null,
|
||||
/** Link to the person's page/profile. */
|
||||
val href: String? = null,
|
||||
) {
|
||||
fun isValid() = name.isNotBlank()
|
||||
|
||||
/**
|
||||
* The Nostr pubkey (hex) this person points at, when [href] is (or embeds) an `npub`/`nprofile`
|
||||
* — including `nostr:` URIs and `njump.me`-style links. Null for a plain web link or no href.
|
||||
* Lets a client upgrade a free-text credit to a real Nostr profile when the publisher linked one.
|
||||
*/
|
||||
fun nostrPubKey(): HexKey? =
|
||||
href?.let {
|
||||
when (val entity = Nip19Parser.uriToRoute(it)?.entity) {
|
||||
is NPub -> entity.hex
|
||||
is NProfile -> entity.hex
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
/**
|
||||
* Spec-neutral view of a podcast show (the channel-level metadata), the companion of
|
||||
* [PodcastEpisode].
|
||||
*
|
||||
* Two competing drafts model the show differently:
|
||||
* - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent],
|
||||
* `kind:10154`): a dedicated replaceable event whose own pubkey *is* the podcast, with the
|
||||
* show fields in tags.
|
||||
* - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata],
|
||||
* a view over a `kind:30078` NIP-78 app-data event with `d="podcast-metadata"`): the creator's
|
||||
* pubkey owns the show and the fields live in a JSON content blob.
|
||||
*
|
||||
* In both models the show's pubkey is also the author of its episodes, so a single show card and
|
||||
* a single per-show episode list serve both. Feeds and UI depend on this interface to merge them.
|
||||
*/
|
||||
interface PodcastShow {
|
||||
/** The show/podcast name. */
|
||||
fun showTitle(): String?
|
||||
|
||||
/** Cover-art URL, if any. */
|
||||
fun showImage(): String?
|
||||
|
||||
/** Show description/summary, if any. */
|
||||
fun showDescription(): String?
|
||||
|
||||
/** Associated website URLs (possibly empty). */
|
||||
fun showWebsites(): List<String>
|
||||
|
||||
/**
|
||||
* Free-text author/host byline (not a Nostr pubkey), if the draft carries one. NIP-F4 models
|
||||
* authors as pubkeys with roles instead, so it leaves this null.
|
||||
*/
|
||||
fun showAuthor(): String? = null
|
||||
|
||||
/** Genre/category labels (e.g. "Technology"), possibly empty. */
|
||||
fun showCategories(): List<String> = emptyList()
|
||||
|
||||
/** Donation/funding page URLs (Podcasting 2.0 `funding`), possibly empty. */
|
||||
fun showFundingUrls(): List<String> = emptyList()
|
||||
|
||||
/** Whether the show is flagged as explicit. */
|
||||
fun showIsExplicit(): Boolean = false
|
||||
|
||||
/** Whether the show is marked complete/finished (no further episodes expected). */
|
||||
fun showIsComplete(): Boolean = false
|
||||
|
||||
/** Copyright line, if provided. */
|
||||
fun showCopyright(): String? = null
|
||||
|
||||
/** The show's default value-for-value split block, if any. NIP-F4 has no V4V and returns null. */
|
||||
fun showValue(): PodcastValue? = null
|
||||
|
||||
/**
|
||||
* Show-level hosts/guests (Podcasting-2.0 `podcast:person`) — the recurring cast credited across
|
||||
* the podcast. Empty when none are declared (NIP-F4 uses author p-tags instead and returns empty).
|
||||
*/
|
||||
fun showPersons(): List<PodcastPerson> = emptyList()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user