From 3d822dac073e278b1d203ae70d826a742e08be17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 18:38:10 +0000 Subject: [PATCH 01/39] feat(quartz): merge NIP-F4 and Podcasting-2.0 podcast kinds into one episode list Amethyst's podcast support (NIP-F4) and derekross/podstr use incompatible identity models: NIP-F4 makes each podcast its own keypair with regular kind:54 episodes, while the Podcasting-2.0 draft signs editable, addressable kind:30054 episodes with the human creator's key. The two cannot share a wire kind, but a client can still render them in one list. Add the Podcasting-2.0 episode (kind:30054) and trailer (kind:30055) event classes plus their tags, and introduce a spec-neutral `PodcastEpisode` abstraction (with `PodcastAudio`) that both kind:54 and kind:30054 implement. Feeds and UI can now depend on the shared interface and surface both kind sets in a single, ordered podcast/episode list. NIP-F4 remains Amethyst's publish format; this only adds read/parse support for the Podcasting-2.0 kinds. Register both new kinds in EventFactory and KindNames. Covered by round-trip, spec-example-JSON, and a unified-list test proving both kinds flow through the shared abstraction. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../vitorpamplona/quartz/kinds/KindNames.kt | 4 + .../episode/PodcastEpisodeEvent.kt | 17 ++ .../episode/Podcasting20EpisodeEvent.kt | 129 +++++++++++++++ .../episode/TagArrayBuilderExt.kt | 46 ++++++ .../episode/tags/AudioTag.kt | 58 +++++++ .../episode/tags/DescriptionTag.kt | 39 +++++ .../episode/tags/DurationTag.kt | 40 +++++ .../nipXXPodcasting20/episode/tags/EditTag.kt | 45 ++++++ .../episode/tags/ImageTag.kt | 39 +++++ .../episode/tags/PubDateTag.kt | 47 ++++++ .../episode/tags/TitleTag.kt | 39 +++++ .../trailer/Podcasting20TrailerEvent.kt | 96 ++++++++++++ .../trailer/TagArrayBuilderExt.kt | 45 ++++++ .../trailer/tags/LengthTag.kt | 40 +++++ .../trailer/tags/SeasonTag.kt | 40 +++++ .../nipXXPodcasting20/trailer/tags/TypeTag.kt | 40 +++++ .../nipXXPodcasting20/trailer/tags/UrlTag.kt | 40 +++++ .../quartz/podcasts/PodcastAudio.kt | 38 +++++ .../quartz/podcasts/PodcastEpisode.kt | 63 ++++++++ .../quartz/utils/EventFactory.kt | 4 + .../Podcasting20EpisodeEventTest.kt | 147 ++++++++++++++++++ .../Podcasting20TrailerEventTest.kt | 81 ++++++++++ .../podcasts/UnifiedPodcastEpisodeTest.kt | 88 +++++++++++ 23 files changed, 1225 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 3376178217..5e522ade47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -300,6 +300,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent /** * Human-readable label and defining NIP for a Nostr event kind. @@ -337,6 +339,8 @@ object KindNames { PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"), AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"), FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"), + Podcasting20EpisodeEvent.KIND to KindName("Podcast Episode (Podcasting 2.0)", null), + Podcasting20TrailerEvent.KIND to KindName("Podcast Trailer (Podcasting 2.0)", null), AttestationEvent.KIND to KindName("Attestation", null), AttestationRequestEvent.KIND to KindName("Attestation Request", null), AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt index 183f2702bf..ab6d098e46 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -30,6 +30,8 @@ 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 +50,7 @@ class PodcastEpisodeEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + PodcastEpisode, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") @@ -59,6 +62,20 @@ class PodcastEpisodeEvent( fun audios() = tags.mapNotNull(AudioTag::parse) + override fun episodeTitle() = title() + + override fun episodeImage() = image() + + override fun episodeDescription() = description() + + override fun episodeAudio() = audios().map { PodcastAudio(it.url, it.mediaType) } + + // NIP-F4 episodes carry no duration tag; clients derive it from the audio stream. + override fun episodeDurationInSeconds(): Long? = null + + // NIP-F4 episodes are regular events ordered by their publication time. + override fun episodePublishedAt() = createdAt + companion object { const val KIND = 54 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt new file mode 100644 index 0000000000..1d2192c0f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -0,0 +1,129 @@ +/* + * 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.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.DescriptionTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Podcasting-2.0 draft podcast episode (`kind:30054`), as published by clients like + * derekross/podstr. Unlike NIP-F4 (where the podcast is its own keypair and episodes + * are regular `kind:54` events), here the **human creator** is the keypair and each + * episode is an *addressable* event keyed by its `d` tag, so it can be edited in place. + * + * Implements the spec-neutral [PodcastEpisode] so it lands in the same merged + * episode list as NIP-F4 episodes. + */ +@Immutable +class Podcasting20EpisodeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PodcastEpisode, + 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 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 + + companion object { + const val KIND = 30054 + + fun build( + dTag: String, + title: String, + audios: List, + pubdate: String, + alt: String = "Podcast episode: $title", + description: String? = null, + image: String? = null, + durationInSeconds: Long? = null, + topics: List = emptyList(), + markdownContent: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, markdownContent, createdAt) { + dTag(dTag) + title(title) + audios.forEach { audio(it) } + pubdate(pubdate) + alt(alt) + + description?.let { description(it) } + image?.let { image(it) } + durationInSeconds?.let { duration(it) } + if (topics.isNotEmpty()) hashtags(topics) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..0ca14bb1de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode + +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.DescriptionTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.podcasts.PodcastAudio + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio)) + +fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) + +fun TagArrayBuilder.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds)) + +fun TagArrayBuilder.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt new file mode 100644 index 0000000000..2eb726e0f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode audio tag: `["audio", "", ""]`. + * + * Wire-identical to the NIP-F4 audio tag, so it parses straight into the shared + * [PodcastAudio] holder that the unified [com.vitorpamplona.quartz.podcasts.PodcastEpisode] + * abstraction exposes. + */ +class AudioTag { + companion object { + const val TAG_NAME = "audio" + + fun parse(tag: Array): PodcastAudio? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + return PodcastAudio(tag[1], mediaType) + } + + fun assemble( + url: String, + mediaType: String? = null, + ): Array = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(audio: PodcastAudio) = assemble(audio.url, audio.mediaType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt new file mode 100644 index 0000000000..4cd243e927 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt new file mode 100644 index 0000000000..cc45a71a81 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 episode duration in whole seconds: `["duration", "3600"]`. */ +class DurationTag { + companion object { + const val TAG_NAME = "duration" + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(durationInSeconds: Long) = arrayOf(TAG_NAME, durationInSeconds.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt new file mode 100644 index 0000000000..2deea22cc9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 edit-history pointer: `["edit", ""]`. References + * the event id of the original publication when an addressable episode/trailer is + * updated, so clients can reconstruct edit history. + */ +class EditTag { + companion object { + const val TAG_NAME = "edit" + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(originalEventId: HexKey) = arrayOf(TAG_NAME, originalEventId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt new file mode 100644 index 0000000000..32b96c2cd6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt new file mode 100644 index 0000000000..123b9f256f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 publication date in RFC2822 format, set once when first published + * and preserved across edits: `["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"]`. + * + * The string is kept verbatim — it feeds RSS generation, where the exact RFC2822 + * spelling matters. Feed ordering relies on the event's `created_at` instead, so + * no date parsing is required here. + */ +class PubDateTag { + companion object { + const val TAG_NAME = "pubdate" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(rfc2822Date: String) = arrayOf(TAG_NAME, rfc2822Date) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt new file mode 100644 index 0000000000..71ac61f4e8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt new file mode 100644 index 0000000000..8dfb72cb04 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt @@ -0,0 +1,96 @@ +/* + * 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.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Podcasting-2.0 draft podcast trailer (`kind:30055`), following the Podcast 2.0 + * `` element. Addressable like the episode (`kind:30054`) and + * signed by the human creator's keypair, keyed by its `d` tag. + */ +@Immutable +class Podcasting20TrailerEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + + /** RFC2822 publication date string, kept verbatim for RSS generation. */ + fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse) + + fun lengthInBytes() = tags.firstNotNullOfOrNull(LengthTag::parse) + + fun mimeType() = tags.firstNotNullOfOrNull(TypeTag::parse) + + fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse) + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + companion object { + const val KIND = 30055 + + fun build( + dTag: String, + title: String, + url: String, + pubdate: String, + alt: String = "Podcast trailer: $title", + lengthInBytes: Long? = null, + mimeType: String? = null, + season: Int? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, title, createdAt) { + dTag(dTag) + title(title) + url(url) + pubdate(pubdate) + alt(alt) + + lengthInBytes?.let { length(it) } + mimeType?.let { type(it) } + season?.let { season(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..001925624d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.trailer + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.url(url: String) = addUnique(UrlTag.assemble(url)) + +fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) + +fun TagArrayBuilder.length(lengthInBytes: Long) = addUnique(LengthTag.assemble(lengthInBytes)) + +fun TagArrayBuilder.type(mimeType: String) = addUnique(TypeTag.assemble(mimeType)) + +fun TagArrayBuilder.season(season: Int) = addUnique(SeasonTag.assemble(season)) + +fun TagArrayBuilder.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt new file mode 100644 index 0000000000..4d2ceae27e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 trailer file size in bytes: `["length", "1024000"]`. */ +class LengthTag { + companion object { + const val TAG_NAME = "length" + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(lengthInBytes: Long) = arrayOf(TAG_NAME, lengthInBytes.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt new file mode 100644 index 0000000000..0e487621f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 season number a trailer represents: `["season", "2"]`. */ +class SeasonTag { + companion object { + const val TAG_NAME = "season" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt new file mode 100644 index 0000000000..0c369e7ff8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 trailer MIME type: `["type", "audio/mpeg"]`. */ +class TypeTag { + companion object { + const val TAG_NAME = "type" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt new file mode 100644 index 0000000000..1cd51f9252 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 trailer media URL: `["url", ""]`. */ +class UrlTag { + companion object { + const val TAG_NAME = "url" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt new file mode 100644 index 0000000000..c33494299f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import androidx.compose.runtime.Immutable + +/** + * Spec-neutral audio reference for a podcast episode, used by the shared + * [PodcastEpisode] abstraction so a UI can play episodes regardless of which + * podcast NIP produced them. + * + * Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio + * in identical `["audio", "", ""]` tags; each event maps + * its own tag class into this holder. + */ +@Immutable +class PodcastAudio( + val url: String, + val mediaType: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt new file mode 100644 index 0000000000..05c4b7efaf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +/** + * Spec-neutral view of a single podcast episode. + * + * Two competing podcast drafts publish episodes with incompatible identity models + * and event kinds: + * - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent], + * `kind:54`): a regular event where the *podcast itself* is a Nostr keypair. + * - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent], + * `kind:30054`): an addressable event where the *human creator* is the keypair + * and episodes are editable via their `d` tag. + * + * The two cannot share a single wire kind, but a client can still render them in + * one list. This interface is that merge point: feeds and UI depend on it instead + * of a concrete event, so both kinds flow into the same podcast/episode list. + */ +interface PodcastEpisode { + /** The episode title shown in listings. */ + fun episodeTitle(): String? + + /** Cover/episode artwork URL, if any. */ + fun episodeImage(): String? + + /** Short, plain-text episode description/summary, if any. */ + fun episodeDescription(): String? + + /** + * One or more audio sources for the episode. Multiple entries typically offer + * the same audio in different containers/codecs (e.g. mp3 + opus). + */ + fun episodeAudio(): List + + /** Episode duration in seconds, if the publisher provided it. */ + fun episodeDurationInSeconds(): Long? + + /** + * Unix timestamp (seconds) used to order episodes in a merged feed. Both drafts + * fall back to the event's `created_at`; the Podcasting-2.0 draft additionally + * carries an RFC2822 `pubdate` tag exposed by its own event class. + */ + fun episodePublishedAt(): Long +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c3871af281..3e3a3081a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -309,6 +309,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent interface EventBuilder { fun build( @@ -566,6 +568,8 @@ class EventFactory { PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig) AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig) FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig) + Podcasting20EpisodeEvent.KIND -> Podcasting20EpisodeEvent(id, pubKey, createdAt, tags, content, sig) + Podcasting20TrailerEvent.KIND -> Podcasting20TrailerEvent(id, pubKey, createdAt, tags, content, sig) ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt new file mode 100644 index 0000000000..17808da256 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.edit +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Podcasting20EpisodeEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 30054`() { + assertEquals(30054, Podcasting20EpisodeEvent.KIND) + } + + @Test + fun `build round-trips fields and is addressable by d tag`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "episode-1699123456-abc123def", + title = "The Future of Decentralized Social Media", + audios = listOf(PodcastAudio("https://example.com/episodes/episode-001.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + description = "A deep dive into how protocols like Nostr are changing social media", + image = "https://example.com/artwork/episode-001.jpg", + durationInSeconds = 3600, + topics = listOf("technology", "decentralization"), + markdownContent = "In this episode, we discuss decentralized social media.", + ) + val event = signer.sign(template) + + assertEquals("episode-1699123456-abc123def", event.dTag()) + assertEquals("The Future of Decentralized Social Media", event.title()) + assertEquals("A deep dive into how protocols like Nostr are changing social media", event.description()) + assertEquals("https://example.com/artwork/episode-001.jpg", event.image()) + assertEquals(3600L, event.durationInSeconds()) + assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate()) + assertEquals("In this episode, we discuss decentralized social media.", event.content) + + val audios = event.audios() + assertEquals(1, audios.size) + assertEquals("https://example.com/episodes/episode-001.mp3", audios[0].url) + assertEquals("audio/mpeg", audios[0].mediaType) + + assertTrue(event.topics().containsAll(listOf("technology", "decentralization"))) + assertEquals("Podcast episode: The Future of Decentralized Social Media", event.alt()) + } + + @Test + fun `optional fields default to null`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-2", + title = "No extras", + audios = listOf(PodcastAudio("https://example.com/ep2.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) + val event = signer.sign(template) + + assertNull(event.image()) + assertNull(event.description()) + assertNull(event.durationInSeconds()) + assertNull(event.editsEventId()) + } + + @Test + fun `edit tag tracks the original event id`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-3", + title = "Corrected", + audios = listOf(PodcastAudio("https://example.com/ep3.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) { + edit("abababababababababababababababababababababababababababababababab") + } + val event = signer.sign(template) + + assertEquals("abababababababababababababababababababababababababababababababab", event.editsEventId()) + } + + @Test + fun `parses the spec example json into the typed event via EventFactory`() { + // Verbatim example from derekross/podstr NIP.md (id and sig elided are not + // required for parsing tags; we supply structurally valid placeholders). + val json = + """ + { + "kind": 30054, + "content": "In this episode, we discuss the latest developments in decentralized social media protocols.", + "tags": [ + ["d", "episode-1699123456-abc123def"], + ["title", "The Future of Decentralized Social Media"], + ["audio", "https://example.com/episodes/episode-001.mp3", "audio/mpeg"], + ["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"], + ["alt", "Podcast episode: The Future of Decentralized Social Media"], + ["description", "A deep dive into how protocols like Nostr are changing social media"], + ["image", "https://example.com/artwork/episode-001.jpg"], + ["duration", "3600"], + ["t", "technology"], + ["t", "decentralization"], + ["t", "social-media"] + ], + "created_at": 1699123456, + "pubkey": "0000000000000000000000000000000000000000000000000000000000000001", + "id": "0000000000000000000000000000000000000000000000000000000000000002", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + """.trimIndent() + + val event = Event.fromJson(json) + assertTrue(event is Podcasting20EpisodeEvent) + assertEquals("The Future of Decentralized Social Media", event.title()) + assertEquals(3600L, event.durationInSeconds()) + assertEquals(3, event.topics().size) + assertEquals("https://example.com/episodes/episode-001.mp3", event.audios()[0].url) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt new file mode 100644 index 0000000000..d0e0de60f8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class Podcasting20TrailerEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 30055`() { + assertEquals(30055, Podcasting20TrailerEvent.KIND) + } + + @Test + fun `build round-trips fields`() { + val template = + Podcasting20TrailerEvent.build( + dTag = "trailer-1699123456-xyz789abc", + title = "Season 2 Preview", + url = "https://example.com/trailers/season-2-preview.mp3", + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + lengthInBytes = 1024000, + mimeType = "audio/mpeg", + season = 2, + ) + val event = signer.sign(template) + + assertEquals("trailer-1699123456-xyz789abc", event.dTag()) + assertEquals("Season 2 Preview", event.title()) + assertEquals("https://example.com/trailers/season-2-preview.mp3", event.url()) + assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate()) + assertEquals(1024000L, event.lengthInBytes()) + assertEquals("audio/mpeg", event.mimeType()) + assertEquals(2, event.season()) + // Per spec the content SHOULD carry the trailer title. + assertEquals("Season 2 Preview", event.content) + } + + @Test + fun `optional fields default to null`() { + val template = + Podcasting20TrailerEvent.build( + dTag = "trailer-min", + title = "Teaser", + url = "https://example.com/teaser.mp3", + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) + val event = signer.sign(template) + + assertNull(event.lengthInBytes()) + assertNull(event.mimeType()) + assertNull(event.season()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt new file mode 100644 index 0000000000..8f7d5cd45e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Proves the merge: a NIP-F4 episode (`kind:54`) and a Podcasting-2.0 episode + * (`kind:30054`) — built on different identity models and event kinds — flow into + * a single `List` and expose the same fields through the shared + * abstraction. + */ +class UnifiedPodcastEpisodeTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `both kinds satisfy the shared PodcastEpisode abstraction`() { + val f4 = + signer.sign( + PodcastEpisodeEvent.build( + title = "F4 Episode", + description = "Published under NIP-F4", + audios = listOf(AudioTag("https://example.com/f4.mp3", "audio/mpeg")), + image = "https://example.com/f4.png", + createdAt = 1000, + ), + ) + + val pc20 = + signer.sign( + Podcasting20EpisodeEvent.build( + dTag = "ep-1", + title = "Podcasting 2.0 Episode", + audios = listOf(PodcastAudio("https://example.com/pc20.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + description = "Published under kind 30054", + image = "https://example.com/pc20.png", + durationInSeconds = 1800, + createdAt = 2000, + ), + ) + + // The whole point: one list, mixed kinds, ordered newest-first. + val unified: List = listOf(f4, pc20).sortedByDescending { it.episodePublishedAt() } + + assertEquals(listOf("Podcasting 2.0 Episode", "F4 Episode"), unified.map { it.episodeTitle() }) + + unified.forEach { episode -> + assertEquals("audio/mpeg", episode.episodeAudio().single().mediaType) + } + + // F4 has no duration tag; the Podcasting-2.0 episode carries one. + val byTitle = unified.associateBy { it.episodeTitle() } + assertNull(byTitle["F4 Episode"]!!.episodeDurationInSeconds()) + assertEquals(1800L, byTitle["Podcasting 2.0 Episode"]!!.episodeDurationInSeconds()) + + assertEquals(1000L, byTitle["F4 Episode"]!!.episodePublishedAt()) + assertEquals(2000L, byTitle["Podcasting 2.0 Episode"]!!.episodePublishedAt()) + } +} From bb31f0381bc88b311828a2b77739b467e1282aef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:56:34 +0000 Subject: [PATCH 02/39] feat(amethyst): surface Podcasting-2.0 episodes in the merged podcast feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire kind:30054 (and kind:30055 trailers) end-to-end so Podcasting-2.0 episodes appear alongside NIP-F4 kind:54 episodes in one list: - LocalCache consumes both new kinds as addressable replaceables. - PodcastEpisodesFeedFilter and OnePodcastEpisodesFeedFilter now merge kind:54 (LocalCache.notes) with kind:30054 (LocalCache.addressables), gating on the shared PodcastEpisode interface. - The episode renderer, compact list row, and inline audio player read through PodcastEpisode / PodcastAudio, so one render path serves both kinds; NoteCompose dispatches kind:30054 to it. - Relay subscriptions request kind:30054 alongside kind:54. Read support only — Amethyst still publishes NIP-F4. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/model/LocalCache.kt | 10 ++++++++ .../amethyst/ui/note/NoteCompose.kt | 5 ++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 16 ++++++++----- .../note/types/PodcastEpisodeAudioPlayer.kt | 11 +++++---- .../podcasts/PodcastEpisodeListItem.kt | 14 ++++++----- .../dal/OnePodcastEpisodesFeedFilter.kt | 24 ++++++++++++------- .../podcasts/dal/PodcastEpisodesFeedFilter.kt | 22 ++++++++++------- .../FilterPodcastEventsByAuthors.kt | 6 ++++- 8 files changed, 74 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index fc6a9d14e3..b5a1d5596f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -289,6 +289,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -3763,6 +3765,14 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is Podcasting20EpisodeEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is Podcasting20TrailerEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is LnZapEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a9c17d31f9..feeaedab44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -344,6 +344,7 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -982,6 +983,10 @@ private fun RenderNoteRow( RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is Podcasting20EpisodeEvent -> { + RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is PodcastMetadataEvent -> { RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index faa0258569..330ba7348d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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. @@ -60,14 +60,18 @@ 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() } + val title = remember(noteEvent) { episode.episodeTitle() } + val image = remember(noteEvent) { episode.episodeImage() } + val description = remember(noteEvent) { episode.episodeDescription() } // 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 firstAudio = remember(noteEvent) { episode.episodeAudio().firstOrNull() } // 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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 51cde34882..bcbc5f95fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -35,7 +35,7 @@ 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 // 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 +43,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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index e75275cb42..e96ce2d12c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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,12 +59,14 @@ 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() } + val firstAudio = remember(noteEvent) { episode.episodeAudio().firstOrNull() } + val image = remember(noteEvent) { episode.episodeImage() } val context = LocalContext.current val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index 05522350b6..ab95e43d20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -23,15 +23,19 @@ 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.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]. + * Every episode of a single podcast, authored by [podcastPubkey]. NIP-F4 episodes (kind 54, + * regular events in `LocalCache.notes`) and Podcasting-2.0 episodes (kind 30054, addressable + * events in `LocalCache.addressables`) both implement [PodcastEpisode] and are merged here — + * in both models the show's pubkey is the episode author. Most-recent-first via + * [sortedByDefaultFeedOrder]. */ class OnePodcastEpisodesFeedFilter( val podcastPubkey: HexKey, @@ -41,18 +45,22 @@ class OnePodcastEpisodesFeedFilter( override fun feedKey(): String = "podcast-" + podcastPubkey override fun feed(): List { - val notes = + val regular = cache.notes.filterIntoSet { _, it -> acceptableEvent(it) } - return sort(notes) + val addressable = + cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it -> + acceptableEvent(it) + } + return sort(regular + addressable) } override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { acceptableEvent(it) } private fun acceptableEvent(note: Note): Boolean { - val noteEvent = note.event - return noteEvent is PodcastEpisodeEvent && + val noteEvent = note.event ?: return false + return noteEvent is PodcastEpisode && noteEvent.pubKey == podcastPubkey && !note.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt index 058e07bdac..6bb8070d66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode /** - * Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike - * music tracks/playlists which are addressable. Same follow-list + hidden/blocked - * gate as MusicTracksFeedFilter. + * Merges the two podcast-episode kinds into one list. NIP-F4 episodes (kind 54) are regular + * events in `LocalCache.notes`; Podcasting-2.0 episodes (kind 30054) are addressable events in + * `LocalCache.addressables`. Both implement [PodcastEpisode], so a single accept gate covers + * them. Same follow-list + hidden/blocked gate as MusicTracksFeedFilter. */ class PodcastEpisodesFeedFilter( val account: Account, @@ -54,11 +56,15 @@ class PodcastEpisodesFeedFilter( override fun feed(): List { val params = buildFilterParams(account) - val notes = + val regular = LocalCache.notes.filterIntoSet { _, it -> accept(it, params) } - return sort(notes) + val addressable = + LocalCache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it -> + accept(it, params) + } + return sort(regular + addressable) } override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) @@ -78,8 +84,8 @@ class PodcastEpisodesFeedFilter( note: Note, params: FilterByListParams, ): Boolean { - val noteEvent = note.event - return noteEvent is PodcastEpisodeEvent && + val noteEvent = note.event ?: return false + return noteEvent is PodcastEpisode && params.match(noteEvent, note.relays) && (params.isHiddenList || account.isAcceptable(note)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 4a56858280..2bb75eedf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -29,10 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent // 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) // NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is From b840e3496f6a79b344fa12677cdd6e6e97b96b10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 20:16:38 +0000 Subject: [PATCH 03/39] feat: merge NIP-F4 and Podcasting-2.0 podcast SHOWS into one list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the episode merge: unify show-level metadata across both drafts. quartz: - Add a spec-neutral PodcastShow interface (title/image/description/websites). - NIP-F4 PodcastMetadataEvent (kind:10154) implements it directly. - Add Podcasting20PodcastMetadata, a read-only view over a kind:30078 NIP-78 app-data event with d="podcast-metadata" whose channel fields live in a JSON content blob (lenient parse; unknown keys like value/funding/categories are ignored). resolvePodcastShow()/isPodcastShowEvent() adapt either kind. amethyst: - PodcastsFeedFilter merges kind:10154 with kind:30078 (d="podcast-metadata"), both from LocalCache.addressables, gated by isPodcastShowEvent so the 30078 scan ignores unrelated app-data. - RenderPodcastMetadata renders via PodcastShow; NoteCompose dispatches a podcast-metadata app-data note to it and keeps the text fallback otherwise. Read support only. The kind:30078 metadata still needs a d-constrained relay subscription to arrive proactively — that touches the topNav subassembly plumbing and is left as the remaining step; shows already in cache merge and render today. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/NoteCompose.kt | 23 ++++ .../amethyst/ui/note/types/PodcastMetadata.kt | 18 +-- .../podcasts/dal/PodcastsFeedFilter.kt | 20 ++- .../metadata/PodcastMetadataEvent.kt | 10 ++ .../metadata/PodcastShowResolver.kt | 48 ++++++++ .../metadata/Podcasting20PodcastMetadata.kt | 89 ++++++++++++++ .../quartz/podcasts/PodcastShow.kt | 50 ++++++++ .../Podcasting20PodcastMetadataTest.kt | 115 ++++++++++++++++++ 8 files changed, 359 insertions(+), 14 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index feeaedab44..cee36a11ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -325,6 +325,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 @@ -345,6 +346,7 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -991,6 +993,27 @@ private fun RenderNoteRow( RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is AppSpecificDataEvent -> { + // kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a + // podcast card. Anything else (e.g. a client's own settings) keeps the text fallback. + if (noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) { + RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } else { + RenderTextEvent( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + isBoostedNote = isBoostedNote, + ) + } + } + is DraftWrapEvent -> { RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index c7f829c4c3..37528fb4a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -53,7 +53,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 +65,16 @@ 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 image = remember(noteEvent) { show.showImage() } + val description = remember(noteEvent) { show.showDescription() } + val websites = remember(noteEvent) { show.showWebsites() } + // 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt index cc452a3f93..01d59060d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt @@ -28,11 +28,15 @@ import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastShowEvent /** - * Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in - * `LocalCache.addressables` — same shape as the music-playlists filter. + * Merges show-level podcast metadata from both drafts into one list. NIP-F4 shows (kind 10154) + * and Podcasting-2.0 shows (kind 30078 NIP-78 app-data with `d="podcast-metadata"`) are both + * replaceable, so they live in `LocalCache.addressables`. [isPodcastShowEvent] gates inclusion + * (the kind-30078 scan would otherwise see unrelated app-data, so the `d`-tag check matters). */ class PodcastsFeedFilter( val account: Account, @@ -53,11 +57,15 @@ class PodcastsFeedFilter( override fun feed(): List { val params = buildFilterParams(account) - val notes = + val f4 = LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it -> accept(it, params) } - return sort(notes) + val podcasting20 = + LocalCache.addressables.filterIntoSet(AppSpecificDataEvent.KIND) { _, it -> + accept(it, params) + } + return sort(f4 + podcasting20) } override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) @@ -77,8 +85,8 @@ class PodcastsFeedFilter( note: Note, params: FilterByListParams, ): Boolean { - val noteEvent = note.event - return noteEvent is PodcastMetadataEvent && + val noteEvent = note.event ?: return false + return isPodcastShowEvent(noteEvent) && params.match(noteEvent, note.relays) && (params.isHiddenList || account.isAcceptable(note)) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt index 42874c0747..d2739b18eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag +import com.vitorpamplona.quartz.podcasts.PodcastShow import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -54,6 +55,7 @@ class PodcastMetadataEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PodcastShow, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") @@ -65,6 +67,14 @@ class PodcastMetadataEvent( fun websites() = tags.mapNotNull(WebsiteTag::parse) + override fun showTitle() = title() + + override fun showImage() = image() + + override fun showDescription() = description() + + override fun showWebsites() = websites() + /** * Returns claimed authors and their roles. The spec warns these claims are * unverified — a podcast can name anyone as author. Before surfacing an author diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt new file mode 100644 index 0000000000..443316861d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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.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) + +/** + * Adapts [event] to the spec-neutral [PodcastShow], or returns null if it is not a podcast show + * (or its Podcasting-2.0 JSON content fails to parse). NIP-F4 metadata events implement + * [PodcastShow] directly; Podcasting-2.0 app-data events are wrapped via + * [Podcasting20PodcastMetadata.parse]. + */ +fun resolvePodcastShow(event: Event?): PodcastShow? = + when (event) { + is PodcastMetadataEvent -> event + is AppSpecificDataEvent -> Podcasting20PodcastMetadata.parse(event) + else -> null + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt new file mode 100644 index 0000000000..71f0eb5100 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -0,0 +1,89 @@ +/* + * 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.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.podcasts.PodcastShow +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() }) + + /** Free-text author/host name (not a Nostr pubkey). */ + fun author() = content.author + + fun language() = content.language + + fun isExplicit() = content.explicit ?: false + + /** + * The subset of the Podcasting-2.0 `kind:30078` metadata JSON this client reads. Unknown keys + * (e.g. `value`, `funding`, `categories`, `copyright`) 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 image: String? = null, + val language: String? = null, + val website: String? = null, + val explicit: Boolean? = null, + ) + + companion object { + const val PODCAST_METADATA_D_TAG = "podcast-metadata" + + /** + * Returns a view if [event] is a podcast-metadata app-data event with parseable JSON, + * otherwise null (wrong `d` tag, or non-JSON/encrypted content such as a user's own + * app settings). + */ + fun parse(event: AppSpecificDataEvent): Podcasting20PodcastMetadata? { + if (event.dTag() != PODCAST_METADATA_D_TAG) return null + val content = runCatching { JsonMapper.fromJson(event.content) }.getOrNull() ?: return null + return Podcasting20PodcastMetadata(event, content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt new file mode 100644 index 0000000000..2e0f73a655 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt @@ -0,0 +1,50 @@ +/* + * 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 +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt new file mode 100644 index 0000000000..5c5c2bb363 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastShowEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow +import com.vitorpamplona.quartz.podcasts.PodcastShow +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Podcasting20PodcastMetadataTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + // Verbatim content shape from derekross/podstr NIP.md (kind 30078, d="podcast-metadata"). + private val metadataJson = + """ + { + "title": "My Podcast", + "description": "A podcast about interesting topics", + "author": "John Doe", + "email": "john@example.com", + "image": "https://example.com/artwork.jpg", + "language": "en", + "categories": ["Technology", "Science"], + "explicit": false, + "website": "https://example.com", + "value": { "amount": 100000, "currency": "sat" }, + "type": "episodic", + "complete": false + } + """.trimIndent() + + private fun appDataEvent( + dTag: String, + content: String, + ): AppSpecificDataEvent = signer.sign(AppSpecificDataEvent.build(dTag = dTag, description = content)) + + @Test + fun `parses podcast-metadata json into PodcastShow fields`() { + val event = appDataEvent("podcast-metadata", metadataJson) + val show = Podcasting20PodcastMetadata.parse(event) + + assertTrue(show != null) + assertEquals("My Podcast", show.showTitle()) + assertEquals("A podcast about interesting topics", show.showDescription()) + assertEquals("https://example.com/artwork.jpg", show.showImage()) + assertEquals(listOf("https://example.com"), show.showWebsites()) + assertEquals("John Doe", show.author()) + assertEquals("en", show.language()) + assertFalse(show.isExplicit()) + } + + @Test + fun `ignores app-data events with a different d tag`() { + val event = appDataEvent("amethyst-settings", metadataJson) + assertNull(Podcasting20PodcastMetadata.parse(event)) + assertFalse(isPodcastShowEvent(event)) + } + + @Test + fun `returns null when content is not valid metadata json`() { + val event = appDataEvent("podcast-metadata", "not-json") + assertNull(Podcasting20PodcastMetadata.parse(event)) + } + + @Test + fun `resolver unifies both show kinds into one list`() { + val f4 = + signer.sign( + PodcastMetadataEvent.build( + title = "F4 Show", + image = "https://example.com/f4.png", + description = "NIP-F4 show", + websites = listOf("https://f4.example.com"), + ), + ) + val pc20 = appDataEvent("podcast-metadata", metadataJson) + + val shows: List = listOf(f4, pc20).mapNotNull { resolvePodcastShow(it) } + + assertEquals(2, shows.size) + assertEquals(listOf("F4 Show", "My Podcast"), shows.map { it.showTitle() }) + assertTrue(isPodcastShowEvent(f4)) + assertTrue(isPodcastShowEvent(pc20)) + } +} From fc2a6588c236313496ba60c9061355dc7c0bc2d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 20:26:32 +0000 Subject: [PATCH 04/39] feat(amethyst): subscribe to Podcasting-2.0 show metadata (kind:30078 #d) Closes the read path for podstr-style podcast shows so they arrive proactively instead of only rendering when already cached. makePodcastsFilter now emits two REQs: the existing NIP-F4 kind:10154 shows plus a kind:30078 REQ constrained to `#d=["podcast-metadata"]` (the app-data kind is overloaded, so the d-tag constraint is mandatory). To carry that constraint, an optional `additionalTags` map is threaded through every topNav podcast subassembly variant (authors, follows, muted-authors, global, hashtag, geohash, all-communities, single-community); variants that already pin their own tags (#t/#g/#a) merge it via the new mergeFilterTags helper. The default is null, so episode and NIP-F4 REQs are byte-identical to before. Covered by MergeFilterTagsTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../podcasts/datasource/SubAssemblyHelper.kt | 26 +++++--- .../FilterPodcastEventsByAuthors.kt | 30 +++++++++ .../FilterPodcastEventsByCommunities.kt | 10 ++- .../FilterPodcastEventsByFollows.kt | 3 +- .../FilterPodcastEventsByGeohashes.kt | 5 +- .../FilterPodcastEventsByHashtag.kt | 5 +- .../FilterPodcastEventsGlobal.kt | 2 + .../subassemblies/MergeFilterTagsTest.kt | 65 +++++++++++++++++++ 8 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt index 19b65c62aa..d332342799 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -30,8 +30,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_METADATA_D_FILTER import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity @@ -52,22 +54,28 @@ fun makePodcastsFilter( feedSettings: IFeedTopNavPerRelayFilterSet, since: SincePerRelayMap?, defaultSince: Long? = null, -): List = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) +): List = + // Two REQs: NIP-F4 shows (kind:10154, no tag constraint) plus Podcasting-2.0 shows + // (kind:30078, constrained to `#d=["podcast-metadata"]` so the overloaded app-data kind + // doesn't flood the feed). Both land in the merged PodcastsFeedFilter. + makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) + + makePodcastFilter(feedSettings, PODCASTING20_METADATA_KINDS, since, defaultSince, PODCAST_METADATA_D_FILTER) private fun makePodcastFilter( feedSettings: IFeedTopNavPerRelayFilterSet, kinds: List, since: SincePerRelayMap?, defaultSince: Long?, + additionalTags: Map>? = null, ): List = when (feedSettings) { - is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince) - is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince) - is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince) - is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince) - is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince) - is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince) - is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) - is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince) + is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince, additionalTags) + is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince, additionalTags) + is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince, additionalTags) + is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince, additionalTags) + is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince, additionalTags) + is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince, additionalTags) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince, additionalTags) + is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince, additionalTags) else -> emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 2bb75eedf9..48a5213fe0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -27,9 +27,11 @@ 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. @@ -39,6 +41,28 @@ import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEve internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND) internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) +// Podcasting-2.0 stores show metadata as a kind:30078 NIP-78 app-data event. That kind is +// heavily overloaded, so this REQ MUST be constrained by `#d=["podcast-metadata"]` (see +// [PODCAST_METADATA_D_FILTER]) or it would pull every client's app-data. Kept separate from +// [PODCAST_KINDS] because the NIP-F4 kind:10154 metadata must NOT carry the `#d` constraint. +internal val PODCASTING20_METADATA_KINDS = listOf(AppSpecificDataEvent.KIND) +internal val PODCAST_METADATA_D_FILTER = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)) + +/** + * Merges two relay-filter tag maps, unioning the value lists per key. Used to layer an extra + * constraint (e.g. `#d`) onto a variant that already pins its own tags (`#t`, `#g`, `#a`). + * A null on either side passes the other through unchanged. + */ +internal fun mergeFilterTags( + base: Map>?, + extra: Map>?, +): Map>? = + when { + base == null -> extra + extra == null -> base + else -> (base.keys + extra.keys).associateWith { (base[it].orEmpty() + extra[it].orEmpty()).distinct() } + } + // NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is // its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list — // they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent). @@ -51,6 +75,7 @@ fun filterPodcastEventsByAuthors( kinds: List, authors: Set, since: Long? = null, + additionalTags: Map>? = null, ): List { val authorList = authors.sorted() return listOf( @@ -60,6 +85,7 @@ fun filterPodcastEventsByAuthors( Filter( authors = authorList, kinds = kinds, + tags = additionalTags, limit = 200, since = since, ), @@ -72,6 +98,7 @@ fun filterPodcastEventsByAuthors( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (authorSet.set.isEmpty()) return emptyList() @@ -85,6 +112,7 @@ fun filterPodcastEventsByAuthors( kinds = kinds, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() @@ -95,6 +123,7 @@ fun filterPodcastEventsByMutedAuthors( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (authorSet.set.isEmpty()) return emptyList() @@ -108,6 +137,7 @@ fun filterPodcastEventsByMutedAuthors( kinds = kinds, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index beb0d05142..edfd12d10f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -33,6 +33,7 @@ fun filterPodcastEventsFromAllCommunities( kinds: List, communities: Set, since: Long? = null, + additionalTags: Map>? = null, ): List { val communityList = communities.sorted() val kindsAsStrings = kinds.map { it.toString() } @@ -57,7 +58,7 @@ fun filterPodcastEventsFromAllCommunities( filter = Filter( kinds = kinds, - tags = mapOf("a" to communityList), + tags = mergeFilterTags(mapOf("a" to communityList), additionalTags), limit = communityList.size * 20, since = since, ), @@ -70,6 +71,7 @@ fun filterPodcastEventsByAllCommunities( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (communitySet.set.isEmpty()) return emptyList() @@ -79,6 +81,7 @@ fun filterPodcastEventsByAllCommunities( kinds = kinds, communities = it.value.communities, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } } @@ -89,6 +92,7 @@ fun filterPodcastEventsFromCommunity( community: String, authors: Set?, since: Long? = null, + additionalTags: Map>? = null, ): List { val authorList = authors?.sorted() val kindsAsStrings = kinds.map { it.toString() } @@ -114,7 +118,7 @@ fun filterPodcastEventsFromCommunity( Filter( authors = authorList, kinds = kinds, - tags = mapOf("a" to listOf(community)), + tags = mergeFilterTags(mapOf("a" to listOf(community)), additionalTags), limit = 100, since = since, ), @@ -127,6 +131,7 @@ fun filterPodcastEventsByCommunity( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (communitySet.set.isEmpty()) return emptyList() @@ -137,6 +142,7 @@ fun filterPodcastEventsByCommunity( community = it.value.community, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt index 0b573739d2..4249f4982d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt @@ -29,6 +29,7 @@ fun filterPodcastEventsByFollows( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (followsSet.set.isEmpty()) return emptyList() @@ -38,7 +39,7 @@ fun filterPodcastEventsByFollows( listOfNotNull( it.value.authors?.let { authors -> - filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay) + filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay, additionalTags) }, ).flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt index f746865b0d..7693832ccc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -31,6 +31,7 @@ fun filterPodcastEventsByGeohashes( kinds: List, geotags: Set, since: Long?, + additionalTags: Map>? = null, ): List { if (geotags.isEmpty()) return emptyList() @@ -40,7 +41,7 @@ fun filterPodcastEventsByGeohashes( filter = Filter( kinds = kinds, - tags = mapOf("g" to geotags.sorted()), + tags = mergeFilterTags(mapOf("g" to geotags.sorted()), additionalTags), limit = 100, since = since, ), @@ -53,6 +54,7 @@ fun filterPodcastEventsByGeohashes( kinds: List, since: SincePerRelayMap?, defaultSince: Long?, + additionalTags: Map>? = null, ): List { if (geoSet.set.isEmpty()) return emptyList() @@ -66,6 +68,7 @@ fun filterPodcastEventsByGeohashes( kinds = kinds, geotags = it.value.geotags, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt index 124c464a17..cbd6961074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -31,6 +31,7 @@ fun filterPodcastEventsByHashtag( kinds: List, hashtags: Set, since: Long? = null, + additionalTags: Map>? = null, ): List = listOf( RelayBasedFilter( @@ -38,7 +39,7 @@ fun filterPodcastEventsByHashtag( filter = Filter( kinds = kinds, - tags = mapOf("t" to hashtags.sorted()), + tags = mergeFilterTags(mapOf("t" to hashtags.sorted()), additionalTags), limit = 200, since = since, ), @@ -50,6 +51,7 @@ fun filterPodcastEventsByHashtag( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (hashtagSet.set.isEmpty()) return emptyList() @@ -63,6 +65,7 @@ fun filterPodcastEventsByHashtag( kinds = kinds, hashtags = it.value.hashtags, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt index e0fc0c4cba..95e4778505 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -30,6 +30,7 @@ fun filterPodcastEventsGlobal( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (relays.set.isEmpty()) return emptyList() @@ -42,6 +43,7 @@ fun filterPodcastEventsGlobal( filter = Filter( kinds = kinds, + tags = additionalTags, limit = 200, since = sinceForRelay, ), diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt new file mode 100644 index 0000000000..36f34e1a39 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MergeFilterTagsTest { + @Test + fun `null base passes the extra through`() { + assertEquals(mapOf("d" to listOf("podcast-metadata")), mergeFilterTags(null, PODCAST_METADATA_D_FILTER)) + } + + @Test + fun `null extra passes the base through`() { + val base = mapOf("t" to listOf("tech")) + assertEquals(base, mergeFilterTags(base, null)) + } + + @Test + fun `both null stays null`() { + assertNull(mergeFilterTags(null, null)) + } + + @Test + fun `disjoint keys are unioned - layering d onto an existing t constraint`() { + val merged = mergeFilterTags(mapOf("t" to listOf("tech")), PODCAST_METADATA_D_FILTER) + assertEquals( + mapOf("t" to listOf("tech"), "d" to listOf("podcast-metadata")), + merged, + ) + } + + @Test + fun `same key merges and de-duplicates values`() { + val merged = mergeFilterTags(mapOf("d" to listOf("podcast-metadata")), mapOf("d" to listOf("podcast-metadata", "other"))) + assertEquals(mapOf("d" to listOf("podcast-metadata", "other")), merged) + } + + @Test + fun `the d filter targets the podstr metadata kind and d-tag`() { + assertEquals(listOf(AppSpecificDataEvent.KIND), PODCASTING20_METADATA_KINDS) + assertEquals(mapOf("d" to listOf("podcast-metadata")), PODCAST_METADATA_D_FILTER) + } +} From 76b05bbb85504b2a9dd2914f2ac3dd50a863d910 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:12:47 +0000 Subject: [PATCH 05/39] feat(amethyst): render Podcasting-2.0 trailers (kind:30055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trailers were parsed and cached but invisible. Surface them: - PodcastTrailerListItem: a compact trailer row with a "Trailer" badge (and season, when present) that plays the media through the shared episode audio player via PodcastAudio. Used both on the show page and as the inline renderer (NoteCompose dispatches kind:30055 to it). - OnePodcastEpisodesFeedFilter now also pulls kind:30055 trailers for the show's pubkey; PodcastScreen renders trailer rows distinctly from episodes and excludes them from the header's episode count. - FilterOnePodcast requests the show author's full output — adds kind:30054 (a pre-existing gap) and kind:30055 alongside the NIP-F4 kinds. Two new strings (podcast_trailer, podcast_trailer_season). Read-only; trailers stay scoped to the show page and aren't mixed into the global episodes feed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/NoteCompose.kt | 6 + .../screen/loggedIn/podcasts/PodcastScreen.kt | 15 +- .../podcasts/PodcastTrailerListItem.kt | 133 ++++++++++++++++++ .../dal/OnePodcastEpisodesFeedFilter.kt | 23 +-- .../podcasts/datasource/FilterOnePodcast.kt | 10 +- amethyst/src/main/res/values/strings.xml | 2 + 6 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index cee36a11ad..d7cd351841 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -198,6 +198,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes @@ -347,6 +348,7 @@ 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 @@ -989,6 +991,10 @@ private fun RenderNoteRow( RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is Podcasting20TrailerEvent -> { + PodcastTrailerListItem(baseNote, accountViewModel, nav) + } + is PodcastMetadataEvent -> { RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index b161ad3aab..b599f33aef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @Composable fun PodcastScreen( @@ -181,15 +182,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, metadataEvent, 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt new file mode 100644 index 0000000000..1f24265f4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.podcasts.PodcastAudio + +private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp)) + +/** + * A Podcasting-2.0 trailer (kind 30055) as a compact row, used on a podcast's show page and as + * the inline renderer in [com.vitorpamplona.amethyst.ui.note.NoteCompose]. A "Trailer" badge (with + * the season, when present) distinguishes it from episode rows; the media plays through the same + * player as episodes via the spec-neutral [PodcastAudio]. + */ +@Composable +fun PodcastTrailerListItem( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? Podcasting20TrailerEvent ?: return + + val title = remember(noteEvent) { noteEvent.title() } + val season = remember(noteEvent) { noteEvent.season() } + val media = + remember(noteEvent) { + noteEvent.url()?.let { PodcastAudio(it, noteEvent.mimeType()) } + } + + val context = LocalContext.current + val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.podcast_trailer), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + season?.let { + Text( + text = stringRes(R.string.podcast_trailer_season, it), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + dateStr.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + media?.let { audio -> + PodcastEpisodeAudioPlayer( + audio = audio, + note = note, + title = title, + image = null, + borderModifier = PLAYER_SHAPE, + accountViewModel = accountViewModel, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index ab95e43d20..fd9a704db8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -28,14 +28,16 @@ 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.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import com.vitorpamplona.quartz.podcasts.PodcastEpisode /** - * Every episode of a single podcast, authored by [podcastPubkey]. NIP-F4 episodes (kind 54, - * regular events in `LocalCache.notes`) and Podcasting-2.0 episodes (kind 30054, addressable - * events in `LocalCache.addressables`) both implement [PodcastEpisode] and are merged here — - * in both models the show's pubkey is the episode author. 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, @@ -49,18 +51,23 @@ class OnePodcastEpisodesFeedFilter( cache.notes.filterIntoSet { _, it -> acceptableEvent(it) } - val addressable = + val episodes = cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it -> acceptableEvent(it) } - return sort(regular + addressable) + val trailers = + cache.addressables.filterIntoSet(Podcasting20TrailerEvent.KIND) { _, it -> + acceptableEvent(it) + } + return sort(regular + episodes + trailers) } override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { acceptableEvent(it) } private fun acceptableEvent(note: Note): Boolean { val noteEvent = note.event ?: return false - return noteEvent is PodcastEpisode && + val isShowContent = noteEvent is PodcastEpisode || noteEvent is Podcasting20TrailerEvent + return isShowContent && noteEvent.pubKey == podcastPubkey && !note.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt index e526cf3174..4fbed64bfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -27,14 +27,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 013e8c069e..02dd939ac5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -913,6 +913,8 @@ Podcasts View episodes No episodes found yet + Trailer + Season %1$d %1$d episode %1$d episodes From 7ea8af973d2502d170ff407a9adcf2d7c7d673b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:08:03 +0000 Subject: [PATCH 06/39] feat: surface richer podcast show metadata with a modern card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse and render the Podcasting-2.0 show fields the kind:30078 metadata carries beyond the basics: quartz: - Extend PodcastShow with author, categories, funding URLs, explicit, complete and copyright as interface defaults — so NIP-F4 (kind:10154) needs no change and just returns empties, while Podcasting-2.0 overrides them. - Podcasting20PodcastMetadata now parses categories, funding[], copyright, type, complete, locked, email and guid from the JSON content (value/V4V is still skipped). Covered by expanded tests incl. an all-absent default case. amethyst: - Rebuild the podcast metadata card: an author byline, tinted pills for Completed / Explicit / genre categories, a prominent filled "Support the show" button opening the funding URL, clickable website chips with a globe icon, and a subtle copyright footer — all Material3, no new icons/font subset needed. Read-only. New strings: podcast_explicit/completed/premium/support_show/by_author. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/types/PodcastMetadata.kt | 158 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 5 + .../metadata/Podcasting20PodcastMetadata.kt | 39 ++++- .../quartz/podcasts/PodcastShow.kt | 21 +++ .../Podcasting20PodcastMetadataTest.kt | 34 +++- 5 files changed, 240 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index 37528fb4a8..0fa495ac6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -20,6 +20,7 @@ */ 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.Column @@ -29,6 +30,8 @@ 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.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,12 +39,15 @@ import androidx.compose.runtime.MutableState 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.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.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note @@ -70,9 +76,15 @@ fun RenderPodcastMetadata( val show = remember(noteEvent) { resolvePodcastShow(noteEvent) } ?: return 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() } // 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 } @@ -102,6 +114,48 @@ fun RenderPodcastMetadata( ) } + author?.let { + Text( + text = stringRes(R.string.podcast_by_author, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + 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() } @@ -118,23 +172,47 @@ fun RenderPodcastMetadata( ) } + 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, - ) + WebsiteChip(website) { 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), @@ -163,3 +241,69 @@ fun RenderPodcastMetadata( } } } + +/** A small rounded, tinted pill for a show attribute (explicit, completed, a genre, …). */ +@Composable +private 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 website pill with a globe icon. */ +@Composable +private fun WebsiteChip( + url: String, + 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 = MaterialSymbols.Public, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 02dd939ac5..e3b6bb75cd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -915,6 +915,11 @@ No episodes found yet Trailer Season %1$d + Explicit + Completed + Premium + Support the show + by %1$s %1$d episode %1$d episodes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt index 71f0eb5100..7c2447000f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -49,27 +49,54 @@ class Podcasting20PodcastMetadata( override fun showWebsites() = listOfNotNull(content.website?.takeIf { it.isNotEmpty() }) - /** Free-text author/host name (not a Nostr pubkey). */ - fun author() = content.author + 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() } fun language() = content.language - fun isExplicit() = content.explicit ?: false + /** 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 - * (e.g. `value`, `funding`, `categories`, `copyright`) are ignored by the lenient mapper and - * can be surfaced later without changing the wire format. + * (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 website: String? = null, + val categories: List = emptyList(), val explicit: Boolean? = null, + val website: String? = null, + val copyright: String? = null, + val funding: List = emptyList(), + val locked: Boolean? = null, + val type: String? = null, + val complete: Boolean? = null, + val guid: String? = null, ) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt index 2e0f73a655..10186d08ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt @@ -47,4 +47,25 @@ interface PodcastShow { /** Associated website URLs (possibly empty). */ fun showWebsites(): List + + /** + * Free-text author/host byline (not a Nostr pubkey), if the draft carries one. NIP-F4 models + * authors as pubkeys with roles instead, so it leaves this null. + */ + fun showAuthor(): String? = null + + /** Genre/category labels (e.g. "Technology"), possibly empty. */ + fun showCategories(): List = emptyList() + + /** Donation/funding page URLs (Podcasting 2.0 `funding`), possibly empty. */ + fun showFundingUrls(): List = emptyList() + + /** Whether the show is flagged as explicit. */ + fun showIsExplicit(): Boolean = false + + /** Whether the show is marked complete/finished (no further episodes expected). */ + fun showIsComplete(): Boolean = false + + /** Copyright line, if provided. */ + fun showCopyright(): String? = null } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt index 5c5c2bb363..acd33c8b05 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt @@ -40,7 +40,7 @@ class Podcasting20PodcastMetadataTest { "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), ) - // Verbatim content shape from derekross/podstr NIP.md (kind 30078, d="podcast-metadata"). + // Verbatim content shape from derekross/podstr (kind 30078, d="podcast-metadata"). private val metadataJson = """ { @@ -53,9 +53,13 @@ class Podcasting20PodcastMetadataTest { "categories": ["Technology", "Science"], "explicit": false, "website": "https://example.com", + "copyright": "© 2025 John Doe", + "funding": ["https://example.com/donate", "https://example.com/tip"], + "locked": false, "value": { "amount": 100000, "currency": "sat" }, "type": "episodic", - "complete": false + "complete": true, + "guid": "abc-123" } """.trimIndent() @@ -74,9 +78,31 @@ class Podcasting20PodcastMetadataTest { assertEquals("A podcast about interesting topics", show.showDescription()) assertEquals("https://example.com/artwork.jpg", show.showImage()) assertEquals(listOf("https://example.com"), show.showWebsites()) - assertEquals("John Doe", show.author()) + assertEquals("John Doe", show.showAuthor()) + assertEquals(listOf("Technology", "Science"), show.showCategories()) + assertEquals(listOf("https://example.com/donate", "https://example.com/tip"), show.showFundingUrls()) + assertEquals("© 2025 John Doe", show.showCopyright()) + assertFalse(show.showIsExplicit()) + assertTrue(show.showIsComplete()) assertEquals("en", show.language()) - assertFalse(show.isExplicit()) + assertEquals("john@example.com", show.email()) + assertEquals("episodic", show.type()) + assertEquals("abc-123", show.guid()) + assertFalse(show.isLocked()) + } + + @Test + fun `optional rich fields default to empty or null when absent`() { + val event = appDataEvent("podcast-metadata", """{"title":"Bare","description":"d","image":"i"}""") + val show = Podcasting20PodcastMetadata.parse(event) + + assertTrue(show != null) + assertNull(show.showAuthor()) + assertTrue(show.showCategories().isEmpty()) + assertTrue(show.showFundingUrls().isEmpty()) + assertNull(show.showCopyright()) + assertFalse(show.showIsExplicit()) + assertFalse(show.showIsComplete()) } @Test From 88ba824a6e78387c9acd8cbdae87b9591e3eccff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 22:28:28 +0000 Subject: [PATCH 07/39] feat: read and surface rich Podcasting-2.0 episode tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the episode tags podstr emits beyond the basics — video, episode number, season, transcript and chapters — and surface them in the UI. quartz: - Add VideoTag, EpisodeNumberTag, SeasonTag, TranscriptTag, ChaptersTag plus accessors and builder DSL on Podcasting20EpisodeEvent. - Extend PodcastEpisode with episodeVideo / episodeNumber / episodeSeason / episodeTranscriptUrl / episodeChaptersUrl as interface defaults, so NIP-F4 needs no change. PodcastAudio is now documented as audio-or-video media. - Tests for round-trip + interface access and an all-absent default case. amethyst: - Extract shared PodcastBadge / PodcastLinkChip (PodcastChips.kt) and reuse them in the show card for visual consistency. - Episode card: a season/episode badge, a "Video" badge, and Transcript / Chapters link chips that open the off-event documents; the player now falls back to the video source when an episode ships no audio. - Compact show-page row: an "S2 · E5" prefix on the date line and the same audio→video media fallback. Read-only. New strings for season/episode, video, transcript and chapters. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/types/PodcastChips.kt | 111 ++++++++++++++++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 62 +++++++++- .../amethyst/ui/note/types/PodcastMetadata.kt | 72 +----------- .../podcasts/PodcastEpisodeListItem.kt | 19 ++- amethyst/src/main/res/values/strings.xml | 6 + .../episode/Podcasting20EpisodeEvent.kt | 35 ++++++ .../episode/TagArrayBuilderExt.kt | 15 +++ .../episode/tags/ChaptersTag.kt | 43 +++++++ .../episode/tags/EpisodeNumberTag.kt | 40 +++++++ .../episode/tags/SeasonTag.kt | 40 +++++++ .../episode/tags/TranscriptTag.kt | 40 +++++++ .../episode/tags/VideoTag.kt | 56 +++++++++ .../quartz/podcasts/PodcastAudio.kt | 12 +- .../quartz/podcasts/PodcastEpisode.kt | 18 +++ .../Podcasting20EpisodeEventTest.kt | 32 +++++ 15 files changed, 517 insertions(+), 84 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt new file mode 100644 index 0000000000..bd0840bd8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol + +/** + * A small rounded, tinted pill for a podcast attribute (explicit, completed, a genre, a season/ + * episode number, …). Shared by the show and episode renderers so they read as one design. + */ +@Composable +internal fun PodcastBadge( + label: String, + symbol: MaterialSymbol?, + container: Color, + content: Color, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(container) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + symbol?.let { + Icon( + symbol = it, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = content, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = content, + ) + } +} + +/** A clickable pill that opens an external resource (a website, a transcript, chapters, …). */ +@Composable +internal fun PodcastLinkChip( + label: String, + symbol: MaterialSymbol, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 330ba7348d..dcf370b774 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -22,6 +22,8 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -34,14 +36,18 @@ import androidx.compose.runtime.remember 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.podcasts.PodcastEpisode @@ -51,6 +57,7 @@ import com.vitorpamplona.quartz.podcasts.PodcastEpisode private val PLAYER_BORDER_MODIFIER = Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) +@OptIn(ExperimentalLayoutApi::class) @Composable fun RenderPodcastEpisode( note: Note, @@ -69,16 +76,21 @@ fun RenderPodcastEpisode( val title = remember(noteEvent) { episode.episodeTitle() } val image = remember(noteEvent) { episode.episodeImage() } val description = remember(noteEvent) { episode.episodeDescription() } - // 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) { episode.episodeAudio().firstOrNull() } + // 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() } // 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, @@ -107,6 +119,48 @@ fun RenderPodcastEpisode( ) } + 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 { url -> + PodcastLinkChip(stringRes(R.string.podcast_chapters), MaterialSymbols.Checklist) { + runCatching { uriHandler.openUri(url) } + } + } + } + } + description?.let { val descriptionTags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index 0fa495ac6c..abf0bd2df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -20,7 +20,6 @@ */ 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.Column @@ -30,7 +29,6 @@ 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.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -39,7 +37,6 @@ import androidx.compose.runtime.MutableState 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.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -47,7 +44,6 @@ 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.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note @@ -199,7 +195,7 @@ fun RenderPodcastMetadata( verticalArrangement = Arrangement.spacedBy(Size5dp), ) { websites.forEach { website -> - WebsiteChip(website) { runCatching { uriHandler.openUri(website) } } + PodcastLinkChip(website, MaterialSymbols.Public) { runCatching { uriHandler.openUri(website) } } } } } @@ -241,69 +237,3 @@ fun RenderPodcastMetadata( } } } - -/** A small rounded, tinted pill for a show attribute (explicit, completed, a genre, …). */ -@Composable -private 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 website pill with a globe icon. */ -@Composable -private fun WebsiteChip( - url: String, - 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 = MaterialSymbols.Public, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = url, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index e96ce2d12c..d088629e95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -36,12 +36,14 @@ 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.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.podcasts.PodcastEpisode @@ -65,11 +67,22 @@ fun PodcastEpisodeListItem( val title = remember(noteEvent) { episode.episodeTitle() } val description = remember(noteEvent) { episode.episodeDescription() } - val firstAudio = remember(noteEvent) { episode.episodeAudio().firstOrNull() } + // 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 = @@ -79,7 +92,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, @@ -109,7 +122,7 @@ fun PodcastEpisodeListItem( ) } - firstAudio?.let { audio -> + media?.let { audio -> PodcastEpisodeAudioPlayer( audio = audio, note = note, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e3b6bb75cd..e33d2337e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -920,6 +920,12 @@ Premium Support the show by %1$s + S%1$d · E%2$d + Ep %1$d + Season %1$d + Video + Transcript + Chapters %1$d episode %1$d episodes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt index 1d2192c0f2..658bb0df39 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -31,12 +31,17 @@ 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.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag import com.vitorpamplona.quartz.podcasts.PodcastAudio import com.vitorpamplona.quartz.podcasts.PodcastEpisode import com.vitorpamplona.quartz.utils.TimeUtils @@ -71,6 +76,16 @@ class Podcasting20EpisodeEvent( 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 durationInSeconds() = tags.firstNotNullOfOrNull(DurationTag::parse) /** RFC2822 publication date string, kept verbatim for RSS generation. */ @@ -95,6 +110,16 @@ class Podcasting20EpisodeEvent( override fun episodePublishedAt() = createdAt + override fun episodeVideo() = video() + + override fun episodeNumber() = number() + + override fun episodeSeason() = season() + + override fun episodeTranscriptUrl() = transcriptUrl() + + override fun episodeChaptersUrl() = chaptersUrl() + companion object { const val KIND = 30054 @@ -107,6 +132,11 @@ class Podcasting20EpisodeEvent( description: String? = null, image: String? = null, durationInSeconds: Long? = null, + video: PodcastAudio? = null, + episodeNumber: Int? = null, + season: Int? = null, + transcriptUrl: String? = null, + chaptersUrl: String? = null, topics: List = emptyList(), markdownContent: String = "", createdAt: Long = TimeUtils.now(), @@ -121,6 +151,11 @@ class Podcasting20EpisodeEvent( 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) } if (topics.isNotEmpty()) hashtags(topics) initializer() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt index 0ca14bb1de..aa0f1289a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt @@ -23,12 +23,17 @@ 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.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag import com.vitorpamplona.quartz.podcasts.PodcastAudio fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) @@ -39,6 +44,16 @@ fun TagArrayBuilder.image(url: String) = addUnique(Ima fun TagArrayBuilder.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio)) +fun TagArrayBuilder.video(video: PodcastAudio) = addUnique(VideoTag.assemble(video)) + +fun TagArrayBuilder.episodeNumber(number: Int) = addUnique(EpisodeNumberTag.assemble(number)) + +fun TagArrayBuilder.season(season: Int) = addUnique(SeasonTag.assemble(season)) + +fun TagArrayBuilder.transcript(url: String) = addUnique(TranscriptTag.assemble(url)) + +fun TagArrayBuilder.chapters(url: String) = addUnique(ChaptersTag.assemble(url)) + fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) fun TagArrayBuilder.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt new file mode 100644 index 0000000000..dd24cdf9ab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode chapters file URL: `["chapters", ""]`. Points at a Podcasting-2.0 + * JSON chapters document (timestamped chapter list) hosted off-event. + */ +class ChaptersTag { + companion object { + const val TAG_NAME = "chapters" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt new file mode 100644 index 0000000000..c07d7edf2c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 episode number within its season/show: `["episode", "5"]`. */ +class EpisodeNumberTag { + companion object { + const val TAG_NAME = "episode" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(episodeNumber: Int) = arrayOf(TAG_NAME, episodeNumber.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt new file mode 100644 index 0000000000..eabe9c157f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 season number an episode belongs to: `["season", "2"]`. */ +class SeasonTag { + companion object { + const val TAG_NAME = "season" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt new file mode 100644 index 0000000000..996539a264 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 episode transcript file URL: `["transcript", ""]`. */ +class TranscriptTag { + companion object { + const val TAG_NAME = "transcript" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt new file mode 100644 index 0000000000..f91bc56c6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode video tag: `["video", "", ""]`. The same wire + * shape as the audio tag; an episode MAY ship a video alongside (or instead of) its audio. Parses + * into the shared [PodcastAudio] media holder so a client can play it through the same path. + */ +class VideoTag { + companion object { + const val TAG_NAME = "video" + + fun parse(tag: Array): PodcastAudio? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + return PodcastAudio(tag[1], mediaType) + } + + fun assemble( + url: String, + mediaType: String? = null, + ): Array = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(video: PodcastAudio) = assemble(video.url, video.mediaType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt index c33494299f..4a76869e15 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.quartz.podcasts import androidx.compose.runtime.Immutable /** - * Spec-neutral audio reference for a podcast episode, used by the shared - * [PodcastEpisode] abstraction so a UI can play episodes regardless of which - * podcast NIP produced them. + * Spec-neutral media reference (a URL plus optional MIME type) for a podcast episode, used by the + * shared [PodcastEpisode] abstraction so a UI can play episodes regardless of which podcast NIP + * produced them. Despite the name it covers both audio and video sources. * - * Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio - * in identical `["audio", "", ""]` tags; each event maps - * its own tag class into this holder. + * Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio in identical + * `["audio", "", ""]` tags; the Podcasting-2.0 draft uses the same shape + * for its `video` tag. Each event maps its own tag class into this holder. */ @Immutable class PodcastAudio( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt index 05c4b7efaf..89d25c1cfd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt @@ -60,4 +60,22 @@ interface PodcastEpisode { * 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 } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt index 17808da256..7aa0e22069 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.edit import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastEpisode import com.vitorpamplona.quartz.utils.DeterministicSigner import com.vitorpamplona.quartz.utils.nsecToKeyPair import kotlin.test.Test @@ -90,6 +91,37 @@ class Podcasting20EpisodeEventTest { assertNull(event.description()) assertNull(event.durationInSeconds()) assertNull(event.editsEventId()) + assertNull(event.video()) + assertNull(event.number()) + assertNull(event.season()) + assertNull(event.transcriptUrl()) + assertNull(event.chaptersUrl()) + } + + @Test + fun `rich Podcasting 2 point 0 tags round-trip and surface through the interface`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-rich", + title = "Rich Episode", + audios = listOf(PodcastAudio("https://example.com/ep.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + video = PodcastAudio("https://example.com/ep.mp4", "video/mp4"), + episodeNumber = 5, + season = 2, + transcriptUrl = "https://example.com/ep.srt", + chaptersUrl = "https://example.com/ep.chapters.json", + ) + val episode: PodcastEpisode = signer.sign(template) + + assertEquals("https://example.com/ep.mp4", episode.episodeVideo()?.url) + assertEquals("video/mp4", episode.episodeVideo()?.mediaType) + assertEquals(5, episode.episodeNumber()) + assertEquals(2, episode.episodeSeason()) + assertEquals("https://example.com/ep.srt", episode.episodeTranscriptUrl()) + assertEquals("https://example.com/ep.chapters.json", episode.episodeChaptersUrl()) + // Audio still comes through independently of the video source. + assertEquals("https://example.com/ep.mp3", episode.episodeAudio().single().url) } @Test From 8573e4fd2fccd1529eb5c62f956a0cd9ad5a9bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 23:05:01 +0000 Subject: [PATCH 08/39] feat(cli): publish Podcasting-2.0 podcasts via `amy podcast20` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a separate command group for authoring the Podcasting-2.0 (podstr) kinds, kept distinct from the NIP-F4 `podcast` commands because the 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. amy podcast20 metadata --title T [...] kind:30078 show metadata (JSON body) amy podcast20 episode --title T --audio URL[,URL] [...] kind:30054 episode amy podcast20 trailer --title T --url URL [...] kind:30055 trailer amy podcast20 list [USER] [--limit N] metadata + episodes + trailers Episodes accept the full rich tag set (video, episode/season, transcript, chapters, topics, duration); d-tags and the RFC2822 pubdate are auto-generated when omitted. Thin assembly only — added Podcasting20PodcastMetadata.build() in quartz so JSON-body construction stays out of cli (covered by a round-trip test). Verified end-to-end against the running CLI: all three commands build, sign and emit the expected kinds (30078/30054/30055) with correct d-tags and the --json single-line contract. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../com/vitorpamplona/amethyst/cli/Main.kt | 16 + .../cli/commands/Podcast20Commands.kt | 306 ++++++++++++++++++ .../metadata/Podcasting20PodcastMetadata.kt | 11 + .../Podcasting20PodcastMetadataTest.kt | 26 ++ 4 files changed, 359 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index f457cb6887..d738af64a2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.cli.commands.NotesCommands import com.vitorpamplona.amethyst.cli.commands.NsiteCommands import com.vitorpamplona.amethyst.cli.commands.OfferCommands import com.vitorpamplona.amethyst.cli.commands.OutboxCommand +import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands import com.vitorpamplona.amethyst.cli.commands.PodcastCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand @@ -233,6 +234,7 @@ private suspend fun dispatch(argv: Array): Int { "serve" -> ServeCommand.run(dataDir, tail) "cashu" -> CashuCommands.dispatch(dataDir, tail) "podcast" -> PodcastCommands.dispatch(dataDir, tail) + "podcast20" -> Podcast20Commands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") @@ -478,6 +480,20 @@ 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] + | 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] + | [--topic A,B] [--content MARKDOWN] [--pubdate RFC2822] + | podcast20 trailer --title T --url URL publish a kind:30055 trailer + | [--d ID] [--type MIME] [--length BYTES] [--season N] [--pubdate RFC2822] + | podcast20 list [USER] [--limit N] list a creator's metadata + episodes + trailers + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt new file mode 100644 index 0000000000..a0db3d64d9 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -0,0 +1,306 @@ +/* + * 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.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 java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** + * `amy podcast20 ` — the Podcasting-2.0 draft (derekross/podstr), + * kept separate from the NIP-F4 `podcast` commands because the two models differ: here the + * logged-in account IS the creator and signs everything with its own key, and episodes/trailers + * are addressable (`d`-tag) events that can be edited in place. + * + * metadata publish kind:30078 show metadata (`d=podcast-metadata`, JSON body) + * episode publish a kind:30054 episode + * trailer publish a kind:30055 trailer + * list list a creator's metadata + episodes + trailers + * + * Thin assembly only: events and JSON live in quartz (`Podcasting20EpisodeEvent`, + * `Podcasting20TrailerEvent`, `Podcasting20PodcastMetadata`). + */ +object Podcast20Commands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "podcast20", + tail, + "podcast20 ", + mapOf( + "metadata" to { rest -> metadata(dataDir, rest) }, + "episode" to { rest -> episode(dataDir, rest) }, + "trailer" to { rest -> trailer(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + ), + ) + + private suspend fun metadata( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 metadata requires --title") + + val 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"), + ) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed = ctx.signer.sign(Podcasting20PodcastMetadata.build(content)) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "d" to Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG, + "title" to title, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun episode( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 episode requires --title") + val audioType = args.flag("audio-type") + val audios = + args + .flag("audio") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { PodcastAudio(it, audioType) } + .orEmpty() + if (audios.isEmpty()) return Output.error("bad_args", "podcast20 episode requires --audio URL[,URL…]") + + val 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"), + topics = listFlag(args, "topic"), + markdownContent = args.flag("content", "") ?: "", + ) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "d" to dTag, + "title" to title, + "audios" to audios.map { it.url }, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun trailer( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 trailer requires --title") + val url = args.flag("url") ?: return Output.error("bad_args", "podcast20 trailer requires --url") + val dTag = args.flag("d") ?: generateDTag("trailer") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + Podcasting20TrailerEvent.build( + dTag = dTag, + title = title, + url = url, + pubdate = args.flag("pubdate") ?: rfc2822Now(), + lengthInBytes = args.flag("length")?.toLongOrNull(), + mimeType = args.flag("type"), + season = args.flag("season")?.toIntOrNull(), + ) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "d" to dTag, + "title" to title, + "url" to url, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val limit = args.intFlag("limit", 50) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = + ctx.drain( + relays.associateWith { + listOf( + Filter( + kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND, AppSpecificDataEvent.KIND), + authors = listOf(author), + limit = limit, + ), + ) + }, + ) + val events = received.map { it.second }.distinctBy { it.id } + val show = + events + .filterIsInstance() + .mapNotNull { Podcasting20PodcastMetadata.parse(it) } + .maxByOrNull { it.event.createdAt } + val episodes = + events + .filterIsInstance() + .sortedByDescending { it.createdAt } + .map { + mapOf( + "event_id" to it.id, + "d" to it.dTag(), + "title" to it.title(), + "season" to it.season(), + "episode" to it.number(), + "audios" to it.audios().map { a -> a.url }, + "created_at" to it.createdAt, + ) + } + val trailers = + events + .filterIsInstance() + .sortedByDescending { it.createdAt } + .map { + mapOf( + "event_id" to it.id, + "d" to it.dTag(), + "title" to it.title(), + "url" to it.url(), + "season" to it.season(), + "created_at" to it.createdAt, + ) + } + Output.emit( + mapOf( + "pubkey" to author, + "metadata" to + show?.let { + mapOf( + "title" to it.showTitle(), + "description" to it.showDescription(), + "image" to it.showImage(), + "author" to it.showAuthor(), + "categories" to it.showCategories(), + "funding" to it.showFundingUrls(), + ) + }, + "episode_count" to episodes.size, + "episodes" to episodes, + "trailer_count" to trailers.size, + "trailers" to trailers, + ), + ) + return 0 + } + } + + private fun listFlag( + args: Args, + name: String, + ): List = + args + .flag(name) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + /** A boolean flag maps to `true` when present and `null` when absent, so it's omitted from the JSON. */ + private fun trueIfPresent( + args: Args, + name: String, + ): Boolean? = if (args.bool(name)) true else null + + private fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}" + + /** Current time as an RFC2822 date string (e.g. `Tue, 24 Jun 2025 12:00:00 GMT`), as the spec's `pubdate` expects. */ + private fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT"))) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt index 7c2447000f..79457d18fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -22,8 +22,10 @@ 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.PodcastShow +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.Serializable /** @@ -112,5 +114,14 @@ class Podcasting20PodcastMetadata( val content = runCatching { JsonMapper.fromJson(event.content) }.getOrNull() ?: return null return Podcasting20PodcastMetadata(event, content) } + + /** + * Builds the kind:30078 show-metadata event template (`d="podcast-metadata"`) by serializing + * [content] to its JSON body. Unset/default fields are omitted, keeping the payload minimal. + */ + fun build( + content: Content, + createdAt: Long = TimeUtils.now(), + ): EventTemplate = AppSpecificDataEvent.build(PODCAST_METADATA_D_TAG, JsonMapper.toJson(content), createdAt) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt index acd33c8b05..7eb3aaa773 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt @@ -91,6 +91,32 @@ class Podcasting20PodcastMetadataTest { assertFalse(show.isLocked()) } + @Test + fun `build round-trips a Content through sign and parse`() { + val content = + Podcasting20PodcastMetadata.Content( + title = "Built Show", + description = "made in quartz", + author = "Jane", + image = "https://example.com/c.jpg", + categories = listOf("Tech"), + explicit = true, + funding = listOf("https://example.com/donate"), + complete = false, + ) + val event = signer.sign(Podcasting20PodcastMetadata.build(content)) + + assertEquals("podcast-metadata", event.dTag()) + val parsed = Podcasting20PodcastMetadata.parse(event) + assertTrue(parsed != null) + assertEquals("Built Show", parsed.showTitle()) + assertEquals("Jane", parsed.showAuthor()) + assertEquals(listOf("Tech"), parsed.showCategories()) + assertEquals(listOf("https://example.com/donate"), parsed.showFundingUrls()) + assertTrue(parsed.showIsExplicit()) + assertFalse(parsed.showIsComplete()) + } + @Test fun `optional rich fields default to empty or null when absent`() { val event = appDataEvent("podcast-metadata", """{"title":"Bare","description":"d","image":"i"}""") From e8edd94317a09801b60230f3eff4b76335ff2b69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 23:20:18 +0000 Subject: [PATCH 09/39] test: verify NIP-22 comments work on podcast episodes; add RootScope marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification of kind:1111 comments on podcast episodes: they already work end-to-end (parse, build, route to the thread screen, thread assembly, composer, and the reply/reaction subscriptions all treat any kind as a valid root — nothing gates on the RootScope marker). Added a quartz test that drives the real CommentEvent.replyBuilder path and asserts: - a comment on a Podcasting-2.0 episode (30054) roots on its `a`/`A` address, - a comment on a NIP-F4 episode (54) roots on its `e`/`E` event id, - both are kind:1111 and carry the root-kind tag. Also closes a small consistency gap: every other commentable content type (articles, all video kinds, pictures, highlights, wiki, polls, …) implements the RootScope marker, but the podcast events did not. Add it to PodcastEpisodeEvent, Podcasting20EpisodeEvent and Podcasting20TrailerEvent. Harmless today (no code does `is RootScope`), but it documents intent and future-proofs any such check. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../episode/PodcastEpisodeEvent.kt | 2 + .../episode/Podcasting20EpisodeEvent.kt | 2 + .../trailer/Podcasting20TrailerEvent.kt | 4 +- .../podcasts/PodcastCommentScopeTest.kt | 91 +++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt index ab6d098e46..71816e68cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -25,6 +25,7 @@ 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 @@ -51,6 +52,7 @@ class PodcastEpisodeEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), PodcastEpisode, + RootScope, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt index 658bb0df39..398bbea906 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -27,6 +27,7 @@ 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 @@ -65,6 +66,7 @@ class Podcasting20EpisodeEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), PodcastEpisode, + RootScope, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt index 8dfb72cb04..e9efd02524 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt @@ -26,6 +26,7 @@ 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 @@ -49,7 +50,8 @@ class Podcasting20TrailerEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt new file mode 100644 index 0000000000..8333f24373 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies that NIP-22 (kind:1111) comments scope correctly onto podcast episodes of both drafts. + * The app's comment composer always routes through [CommentEvent.replyBuilder], so this exercises + * exactly the path a "reply to this episode" tap takes — proving comments already work end-to-end. + */ +class PodcastCommentScopeTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `episode events declare themselves as NIP-22 comment roots`() { + // Consistency with every other commentable content type (articles, videos, …). + val pc20 = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "E", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val f4 = + signer.sign( + PodcastEpisodeEvent.build("E", "d", listOf(AudioTag("https://x/a.mp3"))), + ) + assertTrue(pc20 is RootScope) + assertTrue(f4 is RootScope) + } + + @Test + fun `comment on a Podcasting 2 point 0 episode roots on its address`() { + val episode = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "Episode", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Addressable root: the comment carries the episode's `a`/`A` address (30054:pubkey:d). + assertTrue(comment.rootAddressIds().contains(episode.addressTag())) + assertTrue(comment.hasRootScopeKind(Podcasting20EpisodeEvent.KIND.toString())) + assertTrue(comment.rootEventIds().contains(episode.id)) + } + + @Test + fun `comment on a NIP-F4 episode roots on its event id`() { + val episode = + signer.sign( + PodcastEpisodeEvent.build("Episode", "notes", listOf(AudioTag("https://x/a.mp3"))), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Regular (non-addressable) root: scoped by event id via the `e`/`E` tag, no address root. + assertTrue(comment.rootEventIds().contains(episode.id)) + assertTrue(comment.hasRootScopeKind(PodcastEpisodeEvent.KIND.toString())) + assertTrue(comment.rootAddressIds().isEmpty()) + } +} From d90165a4f61692cfad1af5882327155e13b657e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 00:35:10 +0000 Subject: [PATCH 10/39] =?UTF-8?q?feat:=20Podcasting-2.0=20value-for-value?= =?UTF-8?q?=20(V4V)=20splits=20=E2=80=94=20parse,=20display,=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Podcasting-2.0 `value` block first-class across read and publish. Actual Lightning execution (keysend to node recipients, LNURL fan-out to lnaddress recipients, weighted by split) is a separate wallet/NWC effort and is NOT done here — this lands the data model, display, and authoring. quartz: - PodcastValue / PodcastValueRecipient (@Serializable): amount, currency, recipients[] (name, type node|lnaddress, address, split weight, fee, custom*). - Episode `["value", ""]` tag (ValueTag) + accessor/builder; show value is parsed from the kind:30078 JSON. Exposed via the shared abstraction as PodcastEpisode.episodeValue() and PodcastShow.showValue() (interface defaults, so NIP-F4 returns null). Round-trip + JSON-parse tests. amethyst: - PodcastValueSplits: a tinted "Value-for-Value" card listing each recipient with its address and computed share, rendered on both the episode and show cards when a value block is present. cli: - `podcast20 episode`/`metadata` gain `--value-json` to publish the block; malformed JSON is rejected as bad_args. Verified end-to-end against the CLI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/types/PodcastEpisode.kt | 3 + .../amethyst/ui/note/types/PodcastMetadata.kt | 3 + .../ui/note/types/PodcastValueSplits.kt | 124 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + .../com/vitorpamplona/amethyst/cli/Main.kt | 3 +- .../cli/commands/Podcast20Commands.kt | 21 +++ .../episode/Podcasting20EpisodeEvent.kt | 8 ++ .../episode/TagArrayBuilderExt.kt | 4 + .../episode/tags/ValueTag.kt | 46 +++++++ .../metadata/Podcasting20PodcastMetadata.kt | 4 + .../quartz/podcasts/PodcastEpisode.kt | 6 + .../quartz/podcasts/PodcastShow.kt | 3 + .../quartz/podcasts/PodcastValue.kt | 66 ++++++++++ .../Podcasting20EpisodeEventTest.kt | 40 ++++++ .../Podcasting20PodcastMetadataTest.kt | 16 ++- 15 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index dcf370b774..598b9afcfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -84,6 +84,7 @@ fun RenderPodcastEpisode( val episodeNumber = remember(noteEvent) { episode.episodeNumber() } val transcriptUrl = remember(noteEvent) { episode.episodeTranscriptUrl() } val chaptersUrl = remember(noteEvent) { episode.episodeChaptersUrl() } + val value = remember(noteEvent) { episode.episodeValue() } // 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 } } @@ -177,6 +178,8 @@ fun RenderPodcastEpisode( ) } + value?.takeIf { !makeItShort }?.let { PodcastValueSplits(it) } + markdown?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index abf0bd2df7..f90f1f4beb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -81,6 +81,7 @@ fun RenderPodcastMetadata( 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 } @@ -168,6 +169,8 @@ fun RenderPodcastMetadata( ) } + value?.takeIf { !makeItShort }?.let { PodcastValueSplits(it) } + if (fundingUrls.isNotEmpty() && !makeItShort) { val uriHandler = LocalUriHandler.current Button( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt new file mode 100644 index 0000000000..fc82848a9a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt @@ -0,0 +1,124 @@ +/* + * 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 and + * one row per recipient (name/address + its share of the split). This shows where the show or + * episode directs incoming sats; it does not (yet) execute the Lightning payments. + */ +@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), + ) { + 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, + ) + } + + recipients.forEach { recipient -> + val label = recipient.name?.takeIf { it.isNotEmpty() } ?: recipient.address.orEmpty() + val percent = recipient.split * 100 / total + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + recipient.address + ?.takeIf { it.isNotEmpty() && it != label } + ?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e33d2337e9..19c5244834 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -926,6 +926,8 @@ Video Transcript Chapters + Value-for-Value + %1$d%% %1$d episode %1$d episodes diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index d738af64a2..4211bd2509 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -485,11 +485,12 @@ private fun printUsage() { | [--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] - | [--topic A,B] [--content MARKDOWN] [--pubdate RFC2822] + | [--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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt index a0db3d64d9..42fdfc1dc9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -24,12 +24,14 @@ 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 @@ -72,6 +74,10 @@ object Podcast20Commands { ): 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( @@ -90,6 +96,7 @@ object Podcast20Commands { type = args.flag("type"), complete = trueIfPresent(args, "complete"), guid = args.flag("guid"), + value = value, ) Context.open(dataDir).use { ctx -> @@ -125,6 +132,10 @@ object Podcast20Commands { ?.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")) } @@ -145,6 +156,7 @@ object Podcast20Commands { season = args.flag("season")?.toIntOrNull(), transcriptUrl = args.flag("transcript"), chaptersUrl = args.flag("chapters"), + value = value, topics = listFlag(args, "topic"), markdownContent = args.flag("content", "") ?: "", ) @@ -299,6 +311,15 @@ object Podcast20Commands { name: String, ): Boolean? = if (args.bool(name)) true else null + /** + * Parses the `--value-json` value-for-value block. Success with null means the flag was absent; + * a failure means it was present but malformed (the caller turns that into a bad_args error). + */ + private fun valueFlag(args: Args): Result { + val json = args.flag("value-json") ?: return Result.success(null) + return runCatching { JsonMapper.fromJson(json) } + } + private fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}" /** Current time as an RFC2822 date string (e.g. `Tue, 24 Jun 2025 12:00:00 GMT`), as the spec's `pubdate` expects. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt index 398bbea906..670214c782 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -42,9 +42,11 @@ import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag 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.PodcastValue import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -88,6 +90,8 @@ class Podcasting20EpisodeEvent( fun chaptersUrl() = tags.firstNotNullOfOrNull(ChaptersTag::parse) + fun value() = tags.firstNotNullOfOrNull(ValueTag::parse) + fun durationInSeconds() = tags.firstNotNullOfOrNull(DurationTag::parse) /** RFC2822 publication date string, kept verbatim for RSS generation. */ @@ -122,6 +126,8 @@ class Podcasting20EpisodeEvent( override fun episodeChaptersUrl() = chaptersUrl() + override fun episodeValue() = value() + companion object { const val KIND = 30054 @@ -139,6 +145,7 @@ class Podcasting20EpisodeEvent( season: Int? = null, transcriptUrl: String? = null, chaptersUrl: String? = null, + value: PodcastValue? = null, topics: List = emptyList(), markdownContent: String = "", createdAt: Long = TimeUtils.now(), @@ -158,6 +165,7 @@ class Podcasting20EpisodeEvent( season?.let { season(it) } transcriptUrl?.let { transcript(it) } chaptersUrl?.let { chapters(it) } + value?.let { value(it) } if (topics.isNotEmpty()) hashtags(topics) initializer() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt index aa0f1289a4..3607a898f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt @@ -33,8 +33,10 @@ import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag 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.PodcastValue fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) @@ -54,6 +56,8 @@ fun TagArrayBuilder.transcript(url: String) = addUniqu fun TagArrayBuilder.chapters(url: String) = addUnique(ChaptersTag.assemble(url)) +fun TagArrayBuilder.value(value: PodcastValue) = addUnique(ValueTag.assemble(value)) + fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) fun TagArrayBuilder.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt new file mode 100644 index 0000000000..6b593e42f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode value-for-value tag: `["value", ""]`, where the value is a JSON + * [PodcastValue] object (with `enabled` for episode-level overrides). Parses leniently — a malformed + * body yields null rather than throwing. + */ +class ValueTag { + companion object { + const val TAG_NAME = "value" + + fun parse(tag: Array): PodcastValue? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return runCatching { JsonMapper.fromJson(tag[1]) }.getOrNull() + } + + fun assemble(value: PodcastValue) = arrayOf(TAG_NAME, JsonMapper.toJson(value)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt index 79457d18fc..61a8030ad9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -25,6 +25,7 @@ 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.PodcastShow +import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.Serializable @@ -63,6 +64,8 @@ class Podcasting20PodcastMetadata( override fun showCopyright() = content.copyright?.takeIf { it.isNotEmpty() } + override fun showValue() = content.value + fun language() = content.language /** Contact email for the show, if provided. */ @@ -99,6 +102,7 @@ class Podcasting20PodcastMetadata( val type: String? = null, val complete: Boolean? = null, val guid: String? = null, + val value: PodcastValue? = null, ) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt index 89d25c1cfd..46a87e606e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt @@ -78,4 +78,10 @@ interface PodcastEpisode { /** 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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt index 10186d08ba..9aca7484bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt @@ -68,4 +68,7 @@ interface PodcastShow { /** 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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt new file mode 100644 index 0000000000..1d32d44956 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +/** + * Podcasting-2.0 value-for-value (V4V) block — how a podcast splits incoming sats across its + * participants. Carried as a JSON object: nested under `value` in the show's `kind:30078` metadata, + * and serialized into the episode's `["value", ""]` tag (where an episode override also sets + * [enabled]). + * + * This is the parsed data model; performing the actual Lightning splits (keysend to `node` + * recipients, LNURL-pay to `lnaddress` recipients, weighted by [PodcastValueRecipient.split]) is a + * separate wallet/NWC concern and is not done here. + */ +@Immutable +@Serializable +class PodcastValue( + /** Episode-level override switch; absent/null on show-level value blocks. */ + val enabled: Boolean? = null, + /** Suggested amount (per the spec, typically per-minute streaming), in [currency] units. */ + val amount: Long? = null, + /** Currency of [amount], e.g. "sat" or "USD". */ + val currency: String? = null, + val recipients: List = emptyList(), +) { + /** Sum of recipient splits, used to turn each [PodcastValueRecipient.split] into a share. */ + fun totalSplit(): Int = recipients.sumOf { it.split } +} + +/** One destination in a [PodcastValue] split. */ +@Immutable +@Serializable +class PodcastValueRecipient( + val name: String? = null, + /** "node" (keysend to a node pubkey) or "lnaddress" (LNURL-pay). */ + val type: String? = null, + /** The node pubkey or lightning address, per [type]. */ + val address: String? = null, + /** Relative weight of this recipient's share (not necessarily a percentage). */ + val split: Int = 0, + val customKey: String? = null, + val customValue: String? = null, + /** When true, this recipient takes its share off the top as a fee before the rest is split. */ + val fee: Boolean? = null, +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt index 7aa0e22069..5eeb714da2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEve import com.vitorpamplona.quartz.nipXXPodcasting20.episode.edit import com.vitorpamplona.quartz.podcasts.PodcastAudio import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient import com.vitorpamplona.quartz.utils.DeterministicSigner import com.vitorpamplona.quartz.utils.nsecToKeyPair import kotlin.test.Test @@ -96,6 +98,44 @@ class Podcasting20EpisodeEventTest { assertNull(event.season()) assertNull(event.transcriptUrl()) assertNull(event.chaptersUrl()) + assertNull(event.value()) + } + + @Test + fun `value-for-value split round-trips through the value tag and the interface`() { + val value = + PodcastValue( + enabled = true, + amount = 1000, + currency = "sat", + recipients = + listOf( + PodcastValueRecipient(name = "Host", type = "lnaddress", address = "host@ln.tips", split = 90), + PodcastValueRecipient(name = "Producer", type = "node", address = "02abcd", split = 10, fee = true), + ), + ) + val episode: PodcastEpisode = + signer.sign( + Podcasting20EpisodeEvent.build( + dTag = "ep-v4v", + title = "V4V", + audios = listOf(PodcastAudio("https://x/a.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + value = value, + ), + ) + + val parsed = episode.episodeValue() + assertTrue(parsed != null) + assertEquals(true, parsed.enabled) + assertEquals("sat", parsed.currency) + assertEquals(100, parsed.totalSplit()) + assertEquals(2, parsed.recipients.size) + assertEquals("Host", parsed.recipients[0].name) + assertEquals("lnaddress", parsed.recipients[0].type) + assertEquals("host@ln.tips", parsed.recipients[0].address) + assertEquals(90, parsed.recipients[0].split) + assertEquals(true, parsed.recipients[1].fee) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt index 7eb3aaa773..774caf9533 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt @@ -56,7 +56,13 @@ class Podcasting20PodcastMetadataTest { "copyright": "© 2025 John Doe", "funding": ["https://example.com/donate", "https://example.com/tip"], "locked": false, - "value": { "amount": 100000, "currency": "sat" }, + "value": { + "amount": 100000, + "currency": "sat", + "recipients": [ + { "name": "Host", "type": "lnaddress", "address": "host@example.com", "split": 100 } + ] + }, "type": "episodic", "complete": true, "guid": "abc-123" @@ -89,6 +95,14 @@ class Podcasting20PodcastMetadataTest { assertEquals("episodic", show.type()) assertEquals("abc-123", show.guid()) assertFalse(show.isLocked()) + + val value = show.showValue() + assertTrue(value != null) + assertEquals("sat", value.currency) + assertEquals(1, value.recipients.size) + assertEquals("Host", value.recipients[0].name) + assertEquals("host@example.com", value.recipients[0].address) + assertEquals(100, value.recipients[0].split) } @Test From 1f5650fe733ad81ada287208350540631b570343 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 01:12:56 +0000 Subject: [PATCH 11/39] feat(amethyst): bookmark podcasts via the existing NIP-51 bookmark list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than build a parallel favorites/subscribe stack for NIP-F4's kind:10054 list, reuse the bookmark list (kind 10003) that already holds multiple kinds via a/e references — a public bookmark matches the "soft public recommendation" intent of the favorites list, and the whole chain (Account.addPublicBookmark branching on addressable vs regular notes, the kind-agnostic Bookmarks feed that resolves both e- and a-tags) already supports it. Add a PodcastBookmarkButton toggle and place it in the show and episode card title rows. Works across all podcast kinds: NIP-F4 shows (10154) / episodes (54) and Podcasting-2.0 shows (30078) / episodes (30054); bookmarked podcasts then appear in the standard Bookmarks screen, rendered through the same cards. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../ui/note/types/PodcastBookmarkButton.kt | 72 +++++++++++++++++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 26 ++++--- .../amethyst/ui/note/types/PodcastMetadata.kt | 24 ++++--- 3 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt new file mode 100644 index 0000000000..d6957ed6d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt @@ -0,0 +1,72 @@ +/* + * 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.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.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. + */ +@Composable +fun PodcastBookmarkButton( + note: Note, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val bookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + val isBookmarked = note in bookmarks.public + + IconButton( + onClick = { + if (isBookmarked) { + accountViewModel.removePublicBookmark(note) + } else { + accountViewModel.addPublicBookmark(note) + } + }, + modifier = modifier, + ) { + Icon( + symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkBorder, + contentDescription = + stringRes( + if (isBookmarked) R.string.remove_from_public_bookmarks else R.string.add_to_public_bookmarks, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 598b9afcfb..690083dfb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -24,6 +24,7 @@ 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 @@ -33,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState 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.graphics.Color @@ -109,15 +111,21 @@ 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index f90f1f4beb..24d6edbfdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -100,15 +100,21 @@ fun RenderPodcastMetadata( .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) } author?.let { From 7b2399eb51fd374b14788417cccf323c15bb8ba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 01:38:00 +0000 Subject: [PATCH 12/39] feat: inline Podcasting-2.0 chapter list (expand-to-load) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the episode "Chapters" affordance from a link-out into an inline, timestamped chapter list. quartz: - PodcastChapters / PodcastChapter (@Serializable) parse the off-event podcast-namespace chapters.json (version + startTime/title/img/url/toc). Lenient parse with a malformed-input test. amethyst: - PodcastChaptersSection fetches the chapters document with the app's preview HTTP client (Tor/proxy aware) off the main thread and renders `timestamp — title` rows in a tinted card; empty/failed renders nothing. - The episode card's "Chapters" chip now toggles this section instead of opening the URL. Fetch is lazy — gated behind the toggle — so scrolling a feed never triggers network. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../ui/note/types/PodcastChaptersSection.kt | 161 ++++++++++++++++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 12 +- .../quartz/podcasts/PodcastChapters.kt | 59 +++++++ .../quartz/podcasts/PodcastChaptersTest.kt | 58 +++++++ 4 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt new file mode 100644 index 0000000000..f5b8232eda --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.podcasts.PodcastChapter +import com.vitorpamplona.quartz.podcasts.PodcastChapters +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync + +private sealed interface ChaptersUiState { + data object Loading : ChaptersUiState + + data class Loaded( + val chapters: List, + ) : ChaptersUiState + + data object Failed : ChaptersUiState +} + +/** + * Fetches the episode's off-event Podcasting-2.0 chapters document on first composition and renders + * it as a tinted list of `timestamp — title` rows. Fetch is lazy (callers gate it behind an expand + * toggle) so scrolling a feed never triggers network. On failure or empty, renders nothing. + */ +@Composable +fun PodcastChaptersSection( + chaptersUrl: String, + accountViewModel: AccountViewModel, +) { + val state by produceState(ChaptersUiState.Loading, chaptersUrl) { + val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl) + val parsed = loadChapters(chaptersUrl, client) + value = if (parsed != null) ChaptersUiState.Loaded(parsed.chapters) else ChaptersUiState.Failed + } + + when (val current = state) { + is ChaptersUiState.Loading -> + Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + + is ChaptersUiState.Failed -> {} + + is ChaptersUiState.Loaded -> { + val chapters = current.chapters + if (chapters.isEmpty()) return + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + chapters.forEach { chapter -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = formatTimestamp(chapter.startSeconds()), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.widthIn(min = 44.dp), + ) + Text( + text = chapter.title ?: stringRes(R.string.podcast_chapters), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +private suspend fun loadChapters( + url: String, + client: OkHttpClient, +): PodcastChapters? = + withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + client.newCall(request).executeAsync().use { response -> + if (response.isSuccessful) PodcastChapters.parse(response.body.string()) else null + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PodcastChapters", "Failed to load chapters from $url", e) + null + } + } + +private fun formatTimestamp(seconds: Long): String { + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val secs = seconds % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, secs) + } else { + "%d:%02d".format(minutes, secs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 690083dfb5..9004502454 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -33,7 +33,10 @@ 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 @@ -87,6 +90,7 @@ fun RenderPodcastEpisode( 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 } } @@ -162,14 +166,18 @@ fun RenderPodcastEpisode( runCatching { uriHandler.openUri(url) } } } - chaptersUrl?.let { url -> + chaptersUrl?.let { PodcastLinkChip(stringRes(R.string.podcast_chapters), MaterialSymbols.Checklist) { - runCatching { uriHandler.openUri(url) } + chaptersExpanded = !chaptersExpanded } } } } + if (chaptersExpanded) { + chaptersUrl?.let { PodcastChaptersSection(it, accountViewModel) } + } + description?.let { val descriptionTags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt new file mode 100644 index 0000000000..22b21fdd88 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.serialization.Serializable + +/** + * The Podcasting-2.0 chapters document referenced by an episode's `chapters` tag — a JSON file + * (per the podcast-namespace `chapters.json` spec) hosted off-event. Episodes carry only the URL; + * a client fetches and parses it into this model to render a tappable chapter list. + */ +@Immutable +@Serializable +class PodcastChapters( + val version: String? = null, + val chapters: List = emptyList(), +) { + companion object { + /** Lenient parse of a chapters.json body; returns null on malformed input. */ + fun parse(json: String): PodcastChapters? = runCatching { JsonMapper.fromJson(json) }.getOrNull() + } +} + +/** One chapter marker. [startTime] is in seconds (may be fractional per the spec). */ +@Immutable +@Serializable +class PodcastChapter( + val startTime: Double = 0.0, + val title: String? = null, + /** Chapter artwork URL (the spec field is `img`). */ + val img: String? = null, + /** A related link for the chapter. */ + val url: String? = null, + /** Whether the chapter should appear in a table of contents; absent means yes. */ + val toc: Boolean? = null, +) { + /** Whole-second start used for seeking/labeling. */ + fun startSeconds(): Long = startTime.toLong() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt new file mode 100644 index 0000000000..470e2e310f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastChaptersTest { + @Test + fun `parses a podcast-namespace chapters json`() { + val json = + """ + { + "version": "1.2.0", + "chapters": [ + { "startTime": 0, "title": "Intro" }, + { "startTime": 73.5, "title": "Topic One", "img": "https://x/c1.jpg", "url": "https://x/ref" }, + { "startTime": 600, "title": "Wrap-up", "toc": false } + ] + } + """.trimIndent() + + val parsed = PodcastChapters.parse(json) + assertTrue(parsed != null) + assertEquals("1.2.0", parsed.version) + assertEquals(3, parsed.chapters.size) + assertEquals("Intro", parsed.chapters[0].title) + assertEquals(0L, parsed.chapters[0].startSeconds()) + assertEquals(73L, parsed.chapters[1].startSeconds()) + assertEquals("https://x/c1.jpg", parsed.chapters[1].img) + assertEquals(false, parsed.chapters[2].toc) + } + + @Test + fun `returns null on malformed json`() { + assertNull(PodcastChapters.parse("not-json")) + } +} From 5984d2004250a514ac24095553c9e1d2f15f47fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 13:48:47 +0000 Subject: [PATCH 13/39] feat: verify NIP-F4 podcast authors against their kind:10064 counter-claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A show's kind:10154 metadata can name any pubkey as an author (host, co-host, editor) via `p` tags, but those claims are unverified — the show can list anyone. NIP-F4 lets the named author publish their own kind:10064 AuthoredPodcastsEvent listing the podcasts they actually author, which closes the loop. On the single-podcast header, render each claimed author as a row (avatar, name, role) and cross-check it: the author's 10064 is fetched + observed lazily via observeNoteEvent, and a "verified" check badge appears only when that 10064 lists this podcast's pubkey. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../loggedIn/podcasts/PodcastAuthors.kt | 143 ++++++++++++++++++ .../screen/loggedIn/podcasts/PodcastHeader.kt | 6 + amethyst/src/main/res/values/strings.xml | 4 + .../PodcastAuthorVerificationTest.kt | 74 +++++++++ 4 files changed, 227 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt new file mode 100644 index 0000000000..33eb5e7aec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag + +/** + * Renders a NIP-F4 podcast's claimed authors (`kind:10154` `p` tags, each a pubkey + role). The + * claims are unverified — the show can name anyone — so each author is cross-checked against their + * own counter-claim ([AuthoredPodcastsEvent], `kind:10064`): a verified check appears only when that + * author's 10064 actually lists this podcast's pubkey. The 10064 is fetched + observed lazily via + * [observeNoteEvent], so it arrives and flips the badge without extra wiring. + */ +@Composable +fun PodcastAuthors( + podcastPubkey: HexKey, + authors: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + authors.forEach { author -> + PodcastAuthorRow(podcastPubkey, author, accountViewModel, nav) + } + } +} + +@Composable +private fun PodcastAuthorRow( + podcastPubkey: HexKey, + author: AuthorTag, + accountViewModel: AccountViewModel, + nav: INav, +) { + var user by remember(author.pubKey) { mutableStateOf(accountViewModel.getUserIfExists(author.pubKey)) } + if (user == null) { + LaunchedEffect(author.pubKey) { + user = accountViewModel.checkGetOrCreateUser(author.pubKey) + } + } + + val authoredNote = + remember(author.pubKey) { + LocalCache.getOrCreateAddressableNote(AuthoredPodcastsEvent.createAddress(author.pubKey)) + } + val authored by observeNoteEvent(authoredNote, accountViewModel) + val verified = authored?.authors(podcastPubkey) == true + + val loadedUser = user ?: return + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(routeFor(loadedUser)) } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClickableUserPicture(loadedUser, 28.dp, accountViewModel) + Column(modifier = Modifier.weight(1f)) { + UsernameDisplay(loadedUser, accountViewModel = accountViewModel) + author.role?.let { + Text( + text = roleLabel(it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + if (verified) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = stringRes(R.string.podcast_author_verified), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun roleLabel(role: String): String = + when (role) { + AuthorTag.ROLE_HOST -> stringRes(R.string.podcast_role_host) + AuthorTag.ROLE_COHOST -> stringRes(R.string.podcast_role_cohost) + AuthorTag.ROLE_EDITOR -> stringRes(R.string.podcast_role_editor) + else -> role + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 35f90c6444..946eb6bf7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -69,6 +69,8 @@ fun PodcastHeader( val image = remember(metadataEvent) { metadataEvent?.image() } val description = remember(metadataEvent) { metadataEvent?.description() } val websites = remember(metadataEvent) { metadataEvent?.websites() ?: emptyList() } + val claimedAuthors = remember(metadataEvent) { metadataEvent?.claimedAuthors() ?: emptyList() } + val podcastPubkey = remember(metadataEvent) { metadataEvent?.pubKey } val tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList } Column(Modifier.fillMaxWidth()) { @@ -122,6 +124,10 @@ fun PodcastHeader( nav = nav, ) } + + if (claimedAuthors.isNotEmpty() && podcastPubkey != null) { + PodcastAuthors(podcastPubkey, claimedAuthors, accountViewModel, nav) + } } // Only render once episodes have actually loaded — avoids flashing "0 episodes" diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 19c5244834..10595a9fc1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -928,6 +928,10 @@ Chapters Value-for-Value %1$d%% + Host + Co-host + Editor + Verified author %1$d episode %1$d episodes diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt new file mode 100644 index 0000000000..5f0de2fffd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The cross-check the UI uses to mark a podcast's claimed author "verified": a show (kind:10154) + * names authors via `p` tags (unverified), and an author's own counter-claim (kind:10064) lists the + * podcasts they actually author. Verified iff the author's 10064 lists this podcast's pubkey. + */ +class PodcastAuthorVerificationTest { + private val podcastSigner = + DeterministicSigner("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + private val authorSigner = + DeterministicSigner("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".nsecToKeyPair()) + + @Test + fun `claimed author with a matching 10064 counter-claim verifies`() { + val authorPubkey = authorSigner.pubKey + val metadata = + podcastSigner.sign( + PodcastMetadataEvent.build( + title = "My Show", + image = "https://x/cover.jpg", + description = "desc", + authors = listOf(AuthorTag(authorPubkey, AuthorTag.ROLE_HOST)), + ), + ) + val podcastPubkey = metadata.pubKey + + val claimed = metadata.claimedAuthors().single() + assertEquals(authorPubkey, claimed.pubKey) + assertEquals(AuthorTag.ROLE_HOST, claimed.role) + + // The author's own 10064 lists this podcast -> the show's claim is verified. + val authored = authorSigner.sign(AuthoredPodcastsEvent.build(listOf(UserTag(podcastPubkey)))) + assertTrue(authored.authors(podcastPubkey)) + } + + @Test + fun `author whose 10064 omits the podcast is not verified`() { + val unrelated = "0000000000000000000000000000000000000000000000000000000000000001" + val authored = authorSigner.sign(AuthoredPodcastsEvent.build(listOf(UserTag(unrelated)))) + assertFalse(authored.authors(podcastSigner.pubKey)) + } +} From a71ba613706bf3448f2dcb15086a4c19dc61e26b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 15:43:22 +0000 Subject: [PATCH 14/39] feat: execute Podcasting-2.0 value-for-value (V4V) Lightning splits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds payment execution to the V4V value blocks that were previously display-only. A "Send value" button on the value card opens the account's zap-amount picker; choosing an amount fans the weighted shares out to every recipient, mirroring how a NIP-57 zap-split is paid. quartz (pure, tested): - PodcastValue.computeShares() — splits a total across recipients by relative weight, honoring `fee` recipients that take their split as a percent off the top. Returns PodcastValueShare (recipient + millisats). - PodcastBoostagram — the satoshis.stream keysend metadata blob carried in TLV record 7629169, with the registered field names and unset fields omitted. - PODCAST_TLV_RECORD / TYPE_NODE / TYPE_LNADDRESS constants. amethyst: - V4VPaymentHandler — the execution engine. lnaddress recipients resolve to a BOLT-11 via LNURL-pay and pay through the user's default source (NWC, CLINK debit, or external wallet intent), same rails as a zap. node recipients pay by NWC keysend (pay_keysend) carrying the boostagram TLV plus any per-recipient custom TLV; keysend is NWC-only, so node recipients are skipped with a clear error when no NWC wallet is configured. - AccountViewModel.payV4V() wrapper + the "Send value" amount picker on the value card, wired for both episode and show value blocks. V4V recipients are raw Lightning destinations, not Nostr users, so there is no zap request and no zap receipt — just the payment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/service/V4VPaymentHandler.kt | 253 ++++++++++++++++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 10 +- .../amethyst/ui/note/types/PodcastMetadata.kt | 10 +- .../ui/note/types/PodcastValueSplits.kt | 90 ++++++- .../ui/screen/loggedIn/AccountViewModel.kt | 49 ++++ amethyst/src/main/res/values/strings.xml | 6 + .../quartz/podcasts/PodcastBoostagram.kt | 57 ++++ .../quartz/podcasts/PodcastValue.kt | 71 +++++ .../quartz/podcasts/PodcastBoostagramTest.kt | 66 +++++ .../quartz/podcasts/PodcastValueShareTest.kt | 93 +++++++ 10 files changed, 699 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt new file mode 100644 index 0000000000..9a545e8aeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -0,0 +1,253 @@ +/* + * 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.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], but the recipients are raw Lightning destinations + * declared in the value block (not Nostr users), so there is no zap request and no zap receipt. Two + * recipient kinds are handled: + * + * - [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. + * - [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. Keysend is + * only available over NWC, so node recipients are skipped (with an error) when no NWC wallet is set + * up. + */ +class V4VPaymentHandler( + val account: Account, +) { + /** A resolved lnaddress share ready to pay: the share plus the BOLT-11 fetched for it. */ + class InvoicePayable( + val share: PodcastValueShare, + val invoice: String, + ) + + suspend fun pay( + value: PodcastValue, + totalMilliSats: Long, + boostagram: PodcastBoostagram, + zappedNote: Note?, + context: Context, + okHttpClient: (String) -> OkHttpClient, + onError: (title: String, message: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayInvoicesViaIntent: (invoices: List) -> Unit, + ) = 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(), + okHttpClient = okHttpClient, + context = context, + onError = onError, + onProgress = { onProgress(it * 0.6f + 0.1f) }, + ) + payInvoices(payables, zappedNote, context, onError, onPayInvoicesViaIntent) { + onProgress(it * 0.25f + 0.7f) + } + } + + onProgress(1f) + } + + /** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */ + private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey() + + private suspend fun payNodeSharesViaKeysend( + shares: List, + boostagram: PodcastBoostagram, + context: Context, + onError: (String, String) -> Unit, + ) { + val metadataTlv = TlvRecord(PodcastValue.PODCAST_TLV_RECORD, hexTlv(boostagram.toJson())) + + shares.forEach { share -> + val pubkey = share.recipient.address ?: return@forEach + + val tlvRecords = mutableListOf(metadataTlv) + val customType = share.recipient.customKey?.toLongOrNull() + val customValue = share.recipient.customValue + if (customType != null && customValue != null) { + tlvRecords.add(TlvRecord(customType, hexTlv(customValue))) + } + + val request = + PayKeysendMethod.create( + amount = share.amountMilliSats, + pubkey = pubkey, + tlvRecords = tlvRecords, + ) + + account.sendNwcRequest(request) { response: Response? -> + if (response is IErrorResponseLike) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response.errorMessage() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + } + } + } + } + + private suspend fun assembleInvoices( + shares: List, + message: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + ): List { + var progress = 0f + return mapNotNullAsync(shares) { share: PodcastValueShare -> + val lnAddress = share.recipient.address ?: return@mapNotNullAsync null + try { + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lnAddress, + milliSats = share.amountMilliSats, + message = message, + nostrRequest = null, + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + progress += 1f / shares.size + onProgress(progress) + InvoicePayable(share, invoice) + } catch (e: LightningAddressResolver.LightningAddressError) { + onError(e.title, e.msg) + null + } catch (e: Exception) { + if (e is CancellationException) throw e + onError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + e.message ?: stringRes(context, R.string.error_parsing_error_message), + ) + null + } + } + } + + private suspend fun payInvoices( + payables: List, + zappedNote: Note?, + context: Context, + onError: (String, String) -> Unit, + onPayInvoicesViaIntent: (List) -> Unit, + onProgress: (percent: Float) -> Unit, + ) { + if (payables.isEmpty()) return + + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.Nwc -> { + var done = 0 + payables.forEach { payable -> + account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response -> + if (response is IErrorResponseLike) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response.errorMessage() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + } + } + done++ + onProgress(done.toFloat() / payables.size) + } + } + + is PaymentSource.ClinkDebit -> { + var done = 0 + payables.forEach { payable -> + val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice) + if (response?.isOk() != true) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + ) + } + done++ + onProgress(done.toFloat() / payables.size) + } + } + + null -> { + onPayInvoicesViaIntent(payables.map { it.invoice }) + onProgress(1f) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 9004502454..a00834012c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -194,7 +194,15 @@ fun RenderPodcastEpisode( ) } - value?.takeIf { !makeItShort }?.let { PodcastValueSplits(it) } + value?.takeIf { !makeItShort }?.let { + PodcastValueSplits( + value = it, + note = note, + episodeName = title, + podcastName = null, + accountViewModel = accountViewModel, + ) + } markdown?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index 24d6edbfdc..fb461c5907 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -175,7 +175,15 @@ fun RenderPodcastMetadata( ) } - value?.takeIf { !makeItShort }?.let { PodcastValueSplits(it) } + value?.takeIf { !makeItShort }?.let { + PodcastValueSplits( + value = it, + note = note, + episodeName = null, + podcastName = title, + accountViewModel = accountViewModel, + ) + } if (fundingUrls.isNotEmpty() && !makeItShort) { val uriHandler = LocalUriHandler.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt index fc82848a9a..e25d8232c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt @@ -22,36 +22,53 @@ 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.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton 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.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.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.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +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.PodcastValue /** - * Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header and - * one row per recipient (name/address + its share of the split). This shows where the show or - * episode directs incoming sats; it does not (yet) execute the Lightning payments. + * Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header, a + * "Send value" button (amount picker that fires the weighted Lightning split via + * [AccountViewModel.payV4V]), and one row per recipient (name/address + its share of the split). */ @Composable -fun PodcastValueSplits(value: PodcastValue) { +fun PodcastValueSplits( + value: PodcastValue, + note: Note, + episodeName: String?, + podcastName: String?, + accountViewModel: AccountViewModel, +) { val recipients = value.recipients.filter { it.split > 0 || it.address != null } if (recipients.isEmpty()) return @@ -69,6 +86,7 @@ fun PodcastValueSplits(value: PodcastValue) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), ) { Icon( symbol = MaterialSymbols.Bolt, @@ -81,7 +99,9 @@ fun PodcastValueSplits(value: PodcastValue) { style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), ) + SendValueButton(value, note, episodeName, podcastName, accountViewModel) } recipients.forEach { recipient -> @@ -122,3 +142,65 @@ fun PodcastValueSplits(value: PodcastValue) { } } } + +/** + * "Send value" button: opens a dropdown of the account's configured zap amounts. Picking one fires + * the V4V split for that many sats through [AccountViewModel.payV4V] (which fans the weighted shares + * out to each recipient). The recipient list is fixed by the show/episode, so the only choice the + * user makes is the total amount. + */ +@Composable +private fun SendValueButton( + value: PodcastValue, + note: Note, + episodeName: String?, + podcastName: String?, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + val choices = remember { accountViewModel.zapAmountChoices() } + + Box { + FilledTonalButton( + onClick = { expanded = true }, + enabled = choices.isNotEmpty(), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringRes(R.string.podcast_value_send), + modifier = Modifier.padding(start = 6.dp), + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + choices.forEach { sats -> + DropdownMenuItem( + text = { Text("$sats ${stringRes(R.string.sats)}") }, + onClick = { + expanded = false + accountViewModel.toastManager.toast( + R.string.podcast_value_for_value, + R.string.podcast_value_sending, + ) + accountViewModel.payV4V( + value = value, + totalSats = sats, + podcastName = podcastName, + episodeName = episodeName, + zappedNote = note, + context = context, + ) + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 23def5aa6b..03acc10578 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.V4VPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus +import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.UiSettingsState @@ -157,6 +159,8 @@ 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.PodcastValue import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -952,6 +956,51 @@ 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). Errors surface on [toastManager]; progress on [onProgress]. + */ + fun payV4V( + value: PodcastValue, + totalSats: Long, + podcastName: String?, + episodeName: String?, + zappedNote: Note?, + context: Context, + onProgress: (Float) -> Unit = {}, + ) = launchSigner { + val boostagram = + PodcastBoostagram( + podcast = podcastName, + episode = episodeName, + action = PodcastBoostagram.ACTION_BOOST, + appName = "Amethyst", + valueMsatTotal = totalSats * 1000, + senderName = account.userProfile().toBestDisplayName(), + ) + + V4VPaymentHandler(account).pay( + value = value, + totalMilliSats = totalSats * 1000, + boostagram = boostagram, + zappedNote = zappedNote, + context = context, + okHttpClient = httpClientBuilder::okHttpClientForMoney, + onError = { title, message -> + toastManager.toast(title, message) + }, + onProgress = onProgress, + onPayInvoicesViaIntent = { invoices -> + invoices.forEach { invoice -> + payViaIntent(invoice, context, onPaid = {}, onError = { + toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it) + }) + } + }, + ) + } + /** * Fire-and-forget NIP-61 nutzap from the zap picker. Picks a mint the * recipient accepts (via their kind:10019) that we also have proofs at, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 10595a9fc1..a53d94fd1a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -932,6 +932,12 @@ Co-host Editor Verified author + Send value + Sending value… + Value sent + Value-for-Value error + This podcast has no payable value recipients. + Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. %1$d episode %1$d episodes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt new file mode 100644 index 0000000000..f45bb32a82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The Podcasting-2.0 keysend metadata blob ("boostagram") carried in TLV record + * [PodcastValue.PODCAST_TLV_RECORD] (7629169). It tells the receiving node which podcast/episode the + * payment is for and how much was sent in total. Field names follow the satoshis.stream convention + * (). + * + * Unset fields are omitted from the JSON ([JsonMapper] does not encode defaults), keeping the record + * small enough to fit comfortably inside a keysend onion. + */ +@Serializable +class PodcastBoostagram( + val podcast: String? = null, + val episode: String? = null, + /** "stream" for per-minute streaming sats, "boost" for a deliberate lump-sum tip. */ + val action: String? = null, + @SerialName("app_name") + val appName: String? = null, + /** Total sats (not millisats) the listener sent across all splits. */ + @SerialName("value_msat_total") + val valueMsatTotal: Long? = null, + val message: String? = null, + @SerialName("sender_name") + val senderName: String? = null, +) { + fun toJson(): String = JsonMapper.toJson(this) + + companion object { + const val ACTION_STREAM = "stream" + const val ACTION_BOOST = "boost" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt index 1d32d44956..5f5cf7c270 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt @@ -46,8 +46,79 @@ class PodcastValue( ) { /** Sum of recipient splits, used to turn each [PodcastValueRecipient.split] into a share. */ fun totalSplit(): Int = recipients.sumOf { it.split } + + /** + * Splits [totalMilliSats] across the recipients per the Podcasting-2.0 value rules and returns + * the non-zero shares (recipient + amount in millisats), preserving recipient order. + * + * - A recipient with [PodcastValueRecipient.fee] = true takes its [PodcastValueRecipient.split] + * as a **percentage of the total**, off the top (e.g. an app/host fee). + * - The remainder is divided among the non-fee recipients **by relative weight** + * ([PodcastValueRecipient.split] / sum of non-fee splits). + * + * Recipients without a payable [PodcastValueRecipient.address] or with a non-positive split are + * ignored. Integer division floors each share, so a few millisats may go unallocated (dust) — + * acceptable for value-for-value streaming. + */ + fun computeShares(totalMilliSats: Long): List { + if (totalMilliSats <= 0) return emptyList() + + val active = recipients.filter { it.split > 0 && !it.address.isNullOrBlank() } + if (active.isEmpty()) return emptyList() + + var feeTotalMillis = 0L + val feeAmounts = HashMap() + for (recipient in active) { + if (recipient.fee == true) { + val millis = totalMilliSats * recipient.split / 100 + if (millis > 0) { + feeAmounts[recipient] = millis + feeTotalMillis += millis + } + } + } + + val remainder = (totalMilliSats - feeTotalMillis).coerceAtLeast(0) + val sharedWeight = active.filter { it.fee != true }.sumOf { it.split } + + val shares = ArrayList(active.size) + for (recipient in active) { + val millis = + if (recipient.fee == true) { + feeAmounts[recipient] ?: 0L + } else if (sharedWeight > 0 && remainder > 0) { + remainder * recipient.split / sharedWeight + } else { + 0L + } + if (millis > 0) shares.add(PodcastValueShare(recipient, millis)) + } + return shares + } + + companion object { + /** + * TLV record type for the Podcasting-2.0 keysend metadata blob (the "boostagram"), a JSON + * object carrying podcast/episode/app/value context. Registered value, used by the whole + * Podcasting-2.0 ecosystem. See . + */ + const val PODCAST_TLV_RECORD: Long = 7629169L + + /** Recipient [PodcastValueRecipient.type] for a keysend to a raw Lightning node pubkey. */ + const val TYPE_NODE = "node" + + /** Recipient [PodcastValueRecipient.type] for an LNURL-pay to a lightning address. */ + const val TYPE_LNADDRESS = "lnaddress" + } } +/** One recipient's resolved share of a [PodcastValue] split, in millisats. */ +@Immutable +class PodcastValueShare( + val recipient: PodcastValueRecipient, + val amountMilliSats: Long, +) + /** One destination in a [PodcastValue] split. */ @Immutable @Serializable diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt new file mode 100644 index 0000000000..da9e03d261 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PodcastBoostagramTest { + @Test + fun `uses satoshis-stream field names and omits unset fields`() { + val json = + PodcastBoostagram( + podcast = "My Show", + episode = "Ep 1", + action = PodcastBoostagram.ACTION_BOOST, + appName = "Amethyst", + valueMsatTotal = 21_000_000L, + ).toJson() + + assertTrue(json.contains("\"podcast\":\"My Show\"")) + assertTrue(json.contains("\"app_name\":\"Amethyst\"")) + assertTrue(json.contains("\"value_msat_total\":21000000")) + assertTrue(json.contains("\"action\":\"boost\"")) + // Unset optionals (message, sender_name) must not appear. + assertFalse(json.contains("message")) + assertFalse(json.contains("sender_name")) + } + + @Test + fun `round-trips through json`() { + val original = + PodcastBoostagram( + podcast = "Show", + action = PodcastBoostagram.ACTION_STREAM, + valueMsatTotal = 1000L, + senderName = "alice", + ) + val parsed = JsonMapper.fromJson(original.toJson()) + + assertEquals("Show", parsed.podcast) + assertEquals(PodcastBoostagram.ACTION_STREAM, parsed.action) + assertEquals(1000L, parsed.valueMsatTotal) + assertEquals("alice", parsed.senderName) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt new file mode 100644 index 0000000000..81d3ce1b10 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PodcastValueShareTest { + private fun node( + name: String, + split: Int, + fee: Boolean? = null, + ) = PodcastValueRecipient(name = name, type = PodcastValue.TYPE_NODE, address = "node-$name", split = split, fee = fee) + + @Test + fun `weighted split with no fees divides by relative weight`() { + val value = + PodcastValue( + recipients = listOf(node("host", 90), node("producer", 10)), + ) + // 100k sats total in millisats. + val shares = value.computeShares(100_000_000L).associate { it.recipient.name to it.amountMilliSats } + + assertEquals(90_000_000L, shares["host"]) + assertEquals(10_000_000L, shares["producer"]) + } + + @Test + fun `fee recipient takes its split as a percent off the top, remainder split by weight`() { + val value = + PodcastValue( + recipients = + listOf( + node("app", 5, fee = true), // 5% fee off the top + node("host", 80), + node("cohost", 20), + ), + ) + val shares = value.computeShares(1_000_000L).associate { it.recipient.name to it.amountMilliSats } + + // 5% of 1,000,000 = 50,000 fee. Remainder 950,000 split 80/20. + assertEquals(50_000L, shares["app"]) + assertEquals(760_000L, shares["host"]) + assertEquals(190_000L, shares["cohost"]) + // No more than the total is ever allocated. + assertTrue(shares.values.sum() <= 1_000_000L) + } + + @Test + fun `recipients without an address or with non-positive split are ignored`() { + val value = + PodcastValue( + recipients = + listOf( + node("host", 100), + PodcastValueRecipient(name = "noaddr", type = PodcastValue.TYPE_NODE, address = null, split = 50), + node("zero", 0), + ), + ) + val shares = value.computeShares(10_000L) + + assertEquals(1, shares.size) + assertEquals("host", shares.single().recipient.name) + assertEquals(10_000L, shares.single().amountMilliSats) + } + + @Test + fun `non-positive total or empty recipients yields no shares`() { + val value = PodcastValue(recipients = listOf(node("host", 100))) + assertTrue(value.computeShares(0L).isEmpty()) + assertTrue(value.computeShares(-5L).isEmpty()) + assertTrue(PodcastValue(recipients = emptyList()).computeShares(1_000L).isEmpty()) + } +} From 1fe9bcec6865a93421d753d263b8d7bff85a8395 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 16:03:56 +0000 Subject: [PATCH 15/39] feat: per-minute V4V streaming payments, gated strictly to real playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the streaming half of Podcasting-2.0 value-for-value: a "Stream sats" toggle on the episode player that, while on, pays the value split once per full minute of playback at a chosen sats/minute rate (boostagram action "stream"). The hard requirement is that it must never pay while the user isn't listening, so accrual is bound tightly to genuine playback rather than a free-running timer: - The control lives inside the player composable, so navigating away, scrolling it out of a feed, or tearing down the screen disposes it and stops streaming. - Each second the engine re-reads the live MediaController.isPlaying and only accrues when audio is actually playing and there's no playback error. The player already pauses itself on background / off-screen / audio-focus loss / error, so every one of those halts accrual for free. A released controller reads as not-playing (guarded). - Only whole, actually-played minutes are billed; a partial minute is dropped when the session ends (never rounded up). This rule is a pure, unit-tested unit (PodcastStreamingAccrual). - The toggle defaults OFF and uses plain remember (not rememberSaveable), so it never silently resumes after a rotation or process death — the user re-opts in. - Streaming is gated to an in-app wallet (NWC / CLINK debit); we never auto-fire an external wallet intent every minute. Per-minute errors are swallowed (no toast spam) while one-off boosts still surface errors. The selected rate is always shown on the toggle and a live "streamed N sats this session" counter makes the spend visible. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../note/types/PodcastEpisodeAudioPlayer.kt | 41 +++- .../ui/note/types/PodcastStreamingControl.kt | 211 ++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 23 +- amethyst/src/main/res/values/strings.xml | 5 + .../podcasts/PodcastStreamingAccrual.kt | 56 +++++ .../podcasts/PodcastStreamingAccrualTest.kt | 73 ++++++ 6 files changed, 391 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index bcbc5f95fe..8b08ee63bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -36,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMedia import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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 @@ -70,10 +72,11 @@ 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() } + + Column(Modifier.fillMaxWidth()) { GetMediaItem( videoUri = audio.url, title = title, @@ -91,13 +94,29 @@ 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, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt new file mode 100644 index 0000000000..625a85dab6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt @@ -0,0 +1,211 @@ +/* + * 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.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] `isPlaying`; it only accrues + * when audio is actually playing and there's no playback error. The player already pauses itself + * on background / off-screen / focus-loss / error, so all of those stop accrual for free. + * - 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 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) + val playing = runCatching { controllerState.controller.isPlaying }.getOrDefault(false) + val healthy = controllerState.playbackError.value == null + if (playing && healthy) { + val minutes = accrual.accrue(STREAM_TICK_MS) + if (minutes > 0) { + val amount = minutes * rate + streamedSats += amount + accountViewModel.payV4V( + value = value, + totalSats = amount, + podcastName = podcastName, + episodeName = episodeName, + zappedNote = note, + context = context, + streaming = true, + ) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringRes(R.string.podcast_value_stream), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + // Rate chip — tap to change sats/minute. + Text( + text = stringRes(R.string.podcast_value_stream_rate, rate.toInt()), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { rateMenuOpen = true }, + ) + DropdownMenu( + expanded = rateMenuOpen, + onDismissRequest = { rateMenuOpen = false }, + ) { + STREAM_RATE_CHOICES.forEach { choice -> + DropdownMenuItem( + text = { Text(stringRes(R.string.podcast_value_stream_rate, choice.toInt())) }, + onClick = { + rate = choice + rateMenuOpen = false + }, + ) + } + } + } + val status = + if (streamedSats > 0L) { + stringRes(R.string.podcast_value_streamed_total, streamedSats.toInt()) + } else { + stringRes(R.string.podcast_value_stream_hint) + } + Text( + text = status, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + + Switch( + checked = enabled, + onCheckedChange = { wantOn -> + if (wantOn && !hasInAppWallet) { + // No NWC/CLINK wallet -> we won't auto-stream; tell the user why and stay off. + accountViewModel.toastManager.toast( + R.string.podcast_value_error_title, + R.string.podcast_value_stream_requires_wallet, + ) + } else { + enabled = wantOn + if (!wantOn) streamedSats = 0L + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 03acc10578..3c7465c2f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -959,7 +959,13 @@ 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). Errors surface on [toastManager]; progress on [onProgress]. + * 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, @@ -968,13 +974,14 @@ class AccountViewModel( episodeName: String?, zappedNote: Note?, context: Context, + streaming: Boolean = false, onProgress: (Float) -> Unit = {}, ) = launchSigner { val boostagram = PodcastBoostagram( podcast = podcastName, episode = episodeName, - action = PodcastBoostagram.ACTION_BOOST, + action = if (streaming) PodcastBoostagram.ACTION_STREAM else PodcastBoostagram.ACTION_BOOST, appName = "Amethyst", valueMsatTotal = totalSats * 1000, senderName = account.userProfile().toBestDisplayName(), @@ -988,14 +995,16 @@ class AccountViewModel( context = context, okHttpClient = httpClientBuilder::okHttpClientForMoney, onError = { title, message -> - toastManager.toast(title, message) + if (!streaming) toastManager.toast(title, message) }, onProgress = onProgress, onPayInvoicesViaIntent = { invoices -> - invoices.forEach { invoice -> - payViaIntent(invoice, context, onPaid = {}, onError = { - toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it) - }) + if (!streaming) { + invoices.forEach { invoice -> + payViaIntent(invoice, context, onPaid = {}, onError = { + toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it) + }) + } } }, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a53d94fd1a..91c31f3fc5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -938,6 +938,11 @@ Value-for-Value error This podcast has no payable value recipients. Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. + Stream sats + %1$d sats/min + Sends value automatically while you listen. + Streamed %1$d sats this session + Connect a Nostr Wallet Connect or debit wallet to stream sats while listening. %1$d episode %1$d episodes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt new file mode 100644 index 0000000000..755cbc6f47 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +/** + * Accumulates *played* time for Podcasting-2.0 per-minute "streaming" payments and tells the caller + * how many whole minutes are now billable. + * + * The safety contract — a listener must never be charged for time they did not actually listen to — + * lives here, kept pure so it can be unit-tested away from the audio stack: + * + * - Only time the caller reports via [accrue] counts. The caller feeds it elapsed time *only while + * audio is genuinely playing*; paused/stopped/backgrounded time is simply never accrued. + * - Only **whole** minutes are ever billed. A partial minute stays pending and is dropped on the + * floor if the session ends (just discard the instance) — so an interrupted minute is free, never + * rounded up. + */ +class PodcastStreamingAccrual( + private val minuteMillis: Long = 60_000L, +) { + private var pendingMillis = 0L + + /** + * Adds [playingMillis] of genuinely-played time and returns the number of whole minutes that + * just became billable (already subtracted from the pending remainder). Non-positive input is + * ignored and returns 0. + */ + fun accrue(playingMillis: Long): Int { + if (playingMillis <= 0L) return 0 + pendingMillis += playingMillis + val minutes = (pendingMillis / minuteMillis).toInt() + pendingMillis -= minutes * minuteMillis + return minutes + } + + /** Played time accrued toward the next minute but not yet billed. Dropped if the session ends. */ + fun pendingMillis(): Long = pendingMillis +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt new file mode 100644 index 0000000000..2a5df005c3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PodcastStreamingAccrualTest { + @Test + fun `bills a whole minute only once it is fully played`() { + val accrual = PodcastStreamingAccrual() + assertEquals(0, accrual.accrue(30_000L)) // 30s played -> nothing billable yet + assertEquals(30_000L, accrual.pendingMillis()) + assertEquals(1, accrual.accrue(30_000L)) // another 30s -> one full minute + assertEquals(0L, accrual.pendingMillis()) + } + + @Test + fun `carries the partial remainder forward instead of rounding up`() { + val accrual = PodcastStreamingAccrual() + // 1m 25s played in one go -> bill 1 minute, keep 25s pending. + assertEquals(1, accrual.accrue(85_000L)) + assertEquals(25_000L, accrual.pendingMillis()) + // 35s more completes the second minute. + assertEquals(1, accrual.accrue(35_000L)) + assertEquals(0L, accrual.pendingMillis()) + } + + @Test + fun `many small playing ticks accumulate to a minute`() { + val accrual = PodcastStreamingAccrual() + var billed = 0 + repeat(59) { billed += accrual.accrue(1_000L) } + assertEquals(0, billed) // 59s, still short + billed += accrual.accrue(1_000L) + assertEquals(1, billed) // 60th second tips it over + } + + @Test + fun `a single long interval bills every full minute it contains`() { + val accrual = PodcastStreamingAccrual() + assertEquals(3, accrual.accrue(190_000L)) // 3m 10s -> 3 minutes + assertEquals(10_000L, accrual.pendingMillis()) + } + + @Test + fun `non-positive ticks never bill and an abandoned partial minute is simply dropped`() { + val accrual = PodcastStreamingAccrual() + assertEquals(0, accrual.accrue(0L)) + assertEquals(0, accrual.accrue(-5_000L)) + assertEquals(0, accrual.accrue(40_000L)) // 40s pending + assertEquals(40_000L, accrual.pendingMillis()) + // Session ends here: the caller discards the instance, so the 40s is never charged. + } +} From dedabe64518561405dc34f9bc16c6eacf5586248 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 16:14:41 +0000 Subject: [PATCH 16/39] fix: only stream V4V sats while audio is audible, not merely "playing" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPlaying stays true when the player is muted (the voice player's mute button sets volume to 0) or when the system media volume is at 0 — so the previous gate could keep spending sats per minute while the user hears nothing (e.g. they muted and pocketed the phone). Hitting pause and locking the screen were already safe (pause flips isPlaying false; a screen-locked podcast that keeps playing is audible listening), but muting was a real silent-spend hole. Tighten the per-minute accrual gate to require the audio is genuinely audible: playing AND no playback error AND in-app controller volume > 0 AND system STREAM_MUSIC volume > 0. The whole read is guarded, so a released controller or missing AudioManager resolves to "not audible" and stops accrual. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../ui/note/types/PodcastStreamingControl.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt index 625a85dab6..5dce4e7177 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt @@ -20,6 +20,8 @@ */ 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 @@ -75,9 +77,11 @@ private const val DEFAULT_STREAM_RATE = 10L * 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] `isPlaying`; it only accrues - * when audio is actually playing and there's no playback error. The player already pauses itself - * on background / off-screen / focus-loss / error, so all of those stop accrual for free. + * - 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 @@ -96,6 +100,7 @@ fun PodcastStreamingControl( 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 @@ -112,9 +117,18 @@ fun PodcastStreamingControl( val accrual = PodcastStreamingAccrual() while (isActive) { delay(STREAM_TICK_MS) - val playing = runCatching { controllerState.controller.isPlaying }.getOrDefault(false) - val healthy = controllerState.playbackError.value == null - if (playing && healthy) { + // 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 From bce50d1d017683c27ac98c3e6c3da5b4ed301fea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 19:37:46 +0000 Subject: [PATCH 17/39] feat: let ExoPlayer handle audio focus (pause on calls / other media apps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared feed/podcast/music ExoPlayer previously set no audio attributes and did not handle audio focus, so a phone call or another media app starting would not pause playback — meaning V4V streaming payments could keep accruing during a call even though the user wasn't really listening. Set USAGE_MEDIA audio attributes with handleAudioFocus = true on the pooled player. ExoPlayer now pauses on focus loss (a call, another app's playback) and ducks for transient interruptions; pausing flips isPlaying false, so the streaming-payment accrual stops with it for free. Tradeoff: in Media3 a muted player still requests focus while playWhenReady is true, so muted feed autoplay now requests audio focus too. Acceptable for correct call/interruption behavior; can be scoped to audio-only players later if it proves disruptive. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../playback/playerPool/ExoPlayerBuilder.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 3a46279de2..0280d36ec6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import android.content.Context import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -67,6 +69,18 @@ class ExoPlayerBuilder( .apply { setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) setLoadControl(feedTunedLoadControl()) + // Let ExoPlayer own audio focus: pause when a call or another media app takes over + // (which flips isPlaying false, so V4V streaming payments stop with it) and duck for + // transient interruptions. USAGE_MEDIA so the system treats it as ordinary playback. + setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + // handleAudioFocus = + true, + ) }.build() .apply { PcmTapRegistry.registerPlayer(this, sink) From 95a2d9c44e8cf5e094e2c13450f9c1145127eb62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 19:45:41 +0000 Subject: [PATCH 18/39] Revert "feat: let ExoPlayer handle audio focus (pause on calls / other media apps)" This reverts commit bce50d1d017683c27ac98c3e6c3da5b4ed301fea. --- .../playback/playerPool/ExoPlayerBuilder.kt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 0280d36ec6..3a46279de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import android.content.Context import androidx.annotation.OptIn -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -69,18 +67,6 @@ class ExoPlayerBuilder( .apply { setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) setLoadControl(feedTunedLoadControl()) - // Let ExoPlayer own audio focus: pause when a call or another media app takes over - // (which flips isPlaying false, so V4V streaming payments stop with it) and duck for - // transient interruptions. USAGE_MEDIA so the system treats it as ordinary playback. - setAudioAttributes( - AudioAttributes - .Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .build(), - // handleAudioFocus = - true, - ) }.build() .apply { PcmTapRegistry.registerPlayer(this, sink) From 97df84ee6564abcb1a219198b3a587fe4c585a62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 20:20:24 +0000 Subject: [PATCH 19/39] feat: in-app Android authoring for Podcasting-2.0 podcasts Adds a full create/edit experience so a creator can publish a podcast from the phone, authoring as themselves (Podcasting-2.0 model: the account is the creator, episodes/trailers are addressable and editable in place). A "Your podcast" hub (mic FAB on the Podcasts feed) shows the creator's show or a create CTA, the new-episode/new-trailer/edit-show entry points, and lists their published episodes and trailers (tap an episode to edit). Three composers: - Episode (kind:30054): cover + audio upload through Blossom/NIP-96 (auto-fills duration/title from the picked file's metadata), title, summary, and a collapsible "More details" section for season/number, video, transcript, chapters, and topics. Create + edit + delete; edits preserve the original pubdate and any value-for-value splits. - Show metadata (kind:30078, d=podcast-metadata): cover + the channel fields, categories/funding as comma lists, episodic/serial toggle, and explicit / complete / locked switches. One per account, create-or-edit in place; the podcast GUID and value block are preserved. - Trailer (kind:30055): title, a short audio/video clip (upload or URL), season. The upload + media-probe mechanics are shared across the three composers in PodcastComposerMedia, mirroring the music-track composer's pattern. Publishing goes through account.signAndComputeBroadcast so events land in LocalCache and broadcast to the creator's outbox relays. This pairs the existing CLI (`amy podcast20`) with a native mobile authoring path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/navigation/AppNavigation.kt | 8 + .../amethyst/ui/navigation/routes/Routes.kt | 11 + .../loggedIn/podcasts/PodcastsScreen.kt | 22 ++ .../authoring/EditPodcastShowScreen.kt | 210 ++++++++++ .../authoring/EditPodcastShowViewModel.kt | 245 ++++++++++++ .../authoring/NewPodcastEpisodeScreen.kt | 371 ++++++++++++++++++ .../authoring/NewPodcastEpisodeViewModel.kt | 330 ++++++++++++++++ .../authoring/NewPodcastTrailerScreen.kt | 204 ++++++++++ .../authoring/NewPodcastTrailerViewModel.kt | 163 ++++++++ .../authoring/PodcastAuthoringScreen.kt | 297 ++++++++++++++ .../authoring/PodcastComposerMedia.kt | 131 +++++++ amethyst/src/main/res/values/strings.xml | 61 +++ 12 files changed, 2053 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f8419635d6..a8fe2b127c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -172,6 +172,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 @@ -358,6 +362,10 @@ fun BuildNavigation( composableFromEnd { PodcastEpisodesScreen(accountViewModel, nav) } composableFromEnd { PodcastsScreen(accountViewModel, nav) } composableFromEndArgs { PodcastScreen(it.pubkey, accountViewModel, nav) } + composableFromEnd { PodcastAuthoringScreen(accountViewModel, nav) } + composableFromEnd { EditPodcastShowScreen(accountViewModel, nav) } + composableFromEndArgs { NewPodcastEpisodeScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } + composableFromEnd { NewPodcastTrailerScreen(accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 063791d81b..a0c28aa487 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -190,6 +190,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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt index 8762972787..2e06dad7e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -20,11 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts +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 +39,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 +90,20 @@ fun PodcastsScreen( } } }, + floatingButton = { + FabBottomBarPadded(nav) { + FloatingActionButton( + onClick = { nav.nav(Route.PodcastAuthoring) }, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + symbol = MaterialSymbols.Mic, + contentDescription = stringRes(R.string.podcast_your_podcast), + tint = Color.White, + ) + } + } + }, accountViewModel = accountViewModel, ) { RefresheableBox(podcastsFeedContentState, true) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt new file mode 100644 index 0000000000..7cdfb9ad8e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -0,0 +1,210 @@ +/* + * 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 } + } + } +} + +@Composable +private fun Field( + state: androidx.compose.runtime.MutableState, + labelRes: Int, + placeholderRes: Int?, + isError: Boolean = false, + capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, + keyboardType: KeyboardType = KeyboardType.Text, +) { + OutlinedTextField( + value = state.value, + onValueChange = { state.value = it }, + label = { Text(stringRes(labelRes)) }, + placeholder = placeholderRes?.let { { Text(stringRes(it)) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = isError, + keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType), + ) +} + +@Composable +private fun SwitchRow( + label: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt new file mode 100644 index 0000000000..1c82ce3b88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt @@ -0,0 +1,245 @@ +/* + * 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.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 com.vitorpamplona.quartz.podcasts.PodcastValue +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext + +/** + * Editor for a creator's Podcasting-2.0 show metadata — the single replaceable `kind:30078` + * (`d="podcast-metadata"`) event whose JSON body holds the channel-level fields. There is one per + * account, so this is always an edit-or-create of the same address; saving replaces it in place. + * + * Mirrors the profile-metadata editor: text fields + a cover upload, no audio. Any value-for-value + * block already on the event is preserved across save (the splits editor is separate). + */ +class EditPodcastShowViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val description = mutableStateOf("") + val author = mutableStateOf("") + val email = mutableStateOf("") + val coverUrl = mutableStateOf("") + val website = mutableStateOf("") + val language = mutableStateOf("") + val categories = mutableStateOf("") + val funding = mutableStateOf("") + val copyright = mutableStateOf("") + + /** "episodic" or "serial" (Podcasting 2.0), or blank for unset. */ + val type = mutableStateOf("") + val explicit = mutableStateOf(false) + val complete = mutableStateOf(false) + val locked = mutableStateOf(false) + + val isSending = mutableStateOf(false) + + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val coverMedia = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(null) + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + /** Fields the editor doesn't surface but must not drop on save. */ + private var preservedGuid: String? = null + private var preservedValue: PodcastValue? = null + private var hasExisting = false + + fun init(accountViewModel: AccountViewModel) { + 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() + preservedValue = existing.showValue() + title.value = existing.showTitle().orEmpty() + description.value = existing.showDescription().orEmpty() + author.value = existing.showAuthor().orEmpty() + email.value = existing.email().orEmpty() + coverUrl.value = existing.showImage().orEmpty() + website.value = existing.showWebsites().firstOrNull().orEmpty() + language.value = existing.language().orEmpty() + categories.value = existing.showCategories().joinToString(", ") + funding.value = existing.showFundingUrls().joinToString(", ") + copyright.value = existing.showCopyright().orEmpty() + type.value = existing.type().orEmpty() + explicit.value = existing.showIsExplicit() + complete.value = existing.showIsComplete() + locked.value = existing.isLocked() + } + } + + fun setPickedCover(uris: ImmutableList) { + coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun clearPickedCover() { + coverMedia.value = null + coverUrl.value = "" + } + + fun isValid(): Boolean = title.value.isNotBlank() + + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return + + val coverOrch = coverMedia.value + val server = selectedServer.value + if (coverOrch != null && server == null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + + val snapshot = + Snapshot( + content = + Podcasting20PodcastMetadata.Content( + title = title.value.trim(), + description = description.value.trim().ifBlank { null }, + author = author.value.trim().ifBlank { null }, + email = email.value.trim().ifBlank { null }, + image = coverUrl.value.trim().ifBlank { null }, + language = language.value.trim().ifBlank { null }, + categories = PodcastComposerMedia.parseCsv(categories.value), + explicit = explicit.value.takeIf { it }, + website = website.value.trim().ifBlank { null }, + copyright = copyright.value.trim().ifBlank { null }, + funding = PodcastComposerMedia.parseCsv(funding.value), + locked = locked.value.takeIf { it }, + type = type.value.trim().ifBlank { null }, + complete = complete.value.takeIf { it }, + guid = preservedGuid, + value = preservedValue, + ), + coverOrchestrator = coverOrch, + server = server, + quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value), + stripMetadata = stripMetadata.value, + appContext = context.applicationContext, + ) + + isSending.value = true + accountViewModel.launchSigner { + try { + val newCoverUrl = + snapshot.coverOrchestrator?.let { + PodcastComposerMedia.upload( + orchestrator = it, + kind = "cover", + account = account, + server = snapshot.server!!, + quality = snapshot.quality, + stripMetadata = snapshot.stripMetadata, + alt = snapshot.content.title, + context = snapshot.appContext, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + } + + val finalContent = newCoverUrl?.let { snapshot.content.copyWithImage(it) } ?: snapshot.content + account.signAndComputeBroadcast(Podcasting20PodcastMetadata.build(finalContent)) + + if (snapshot.coverOrchestrator != null) { + account.settings.changeDefaultFileServer(snapshot.server!!) + account.settings.changeStripLocationOnUpload(snapshot.stripMetadata) + } + + withContext(Dispatchers.Main.immediate) { + coverMedia.value = null + if (newCoverUrl != null) coverUrl.value = newCoverUrl + } + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast( + "Failed to save podcast", + t.message ?: t.javaClass.simpleName, + ) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } + + private class Snapshot( + val content: Podcasting20PodcastMetadata.Content, + val coverOrchestrator: MultiOrchestrator?, + val server: ServerName?, + val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality, + val stripMetadata: Boolean, + val appContext: Context, + ) +} + +/** Copies a metadata content with a new image URL (Content has no copy() — it's a plain class). */ +private fun Podcasting20PodcastMetadata.Content.copyWithImage(image: String): Podcasting20PodcastMetadata.Content = + Podcasting20PodcastMetadata.Content( + title = title, + description = description, + author = author, + email = email, + image = image, + language = language, + categories = categories, + explicit = explicit, + website = website, + copyright = copyright, + funding = funding, + locked = locked, + type = type, + complete = complete, + guid = guid, + value = value, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt new file mode 100644 index 0000000000..1b593d324a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -0,0 +1,371 @@ +/* + * 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), + ) + } + + if (vm.isEditing) { + HorizontalDivider() + DeleteEpisodeRow(vm = vm, onDeleted = { nav.popBack() }, accountViewModel = accountViewModel) + } + } + } +} + +@Composable +private fun UrlField( + state: androidx.compose.runtime.MutableState, + labelRes: Int, +) { + OutlinedTextField( + value = state.value, + onValueChange = { state.value = it }, + label = { Text(stringRes(labelRes)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) +} + +@Composable +private fun AudioFilePickerRow( + pickedName: String?, + onPick: () -> Unit, + onClear: () -> Unit, + enabled: Boolean, +) { + if (pickedName != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp)) + .let { if (enabled) it.clickable(onClick = onPick) else it } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + text = stringRes(R.string.podcast_episode_audio_picked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (enabled) { + TextButton(onClick = onClear) { Text(stringRes(R.string.cancel)) } + } + } + } else { + UploadPlaceholder( + iconSymbol = MaterialSymbols.MusicNote, + ctaRes = R.string.podcast_episode_audio_upload_cta, + hintRes = R.string.podcast_episode_audio_upload_hint, + onClick = onPick, + aspectRatio = null, + enabled = enabled, + ) + } +} + +// Single audio file via OpenDocument restricted to audio MIME types. +@Composable +private fun AudioFileSelect(onAudioPicked: (SelectedMedia?) -> Unit) { + val resolver = LocalContext.current.contentResolver + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> onAudioPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) }, + ) + LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*")) } +} + +@Composable +private fun DeleteEpisodeRow( + vm: NewPodcastEpisodeViewModel, + onDeleted: () -> Unit, + accountViewModel: AccountViewModel, +) { + var confirming by rememberSaveable { mutableStateOf(false) } + + OutlinedButton( + onClick = { confirming = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(text = stringRes(R.string.podcast_episode_delete)) + } + + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringRes(R.string.podcast_episode_delete)) }, + text = { Text(stringRes(R.string.podcast_episode_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + confirming = false + accountViewModel.launchSigner { if (vm.deleteLoaded()) onDeleted() } + }) { + Text(text = stringRes(R.string.podcast_episode_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirming = false }) { Text(stringRes(R.string.cancel)) } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt new file mode 100644 index 0000000000..0ac2b4f3bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt @@ -0,0 +1,330 @@ +/* + * 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.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastValue +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Composer for a Podcasting-2.0 episode (`kind:30054`, addressable). Closely mirrors the music-track + * composer: the user picks a cover image and an audio file, Save uploads them through Blossom/NIP-96, + * then publishes the addressable event with the returned URLs. Power users can paste raw URLs instead. + * + * Edit mode (`editDTag` resolves from LocalCache) re-publishes under the same `d` tag so it replaces + * the prior version in place; the original `pubdate` and any value-for-value block are preserved. + */ +class NewPodcastEpisodeViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val description = mutableStateOf("") + val audioUrl = mutableStateOf("") + val coverUrl = mutableStateOf("") + val durationSeconds = mutableStateOf("") + val episodeNumber = mutableStateOf("") + val season = mutableStateOf("") + val videoUrl = mutableStateOf("") + val transcriptUrl = mutableStateOf("") + val chaptersUrl = mutableStateOf("") + val topics = mutableStateOf("") + + val isSending = mutableStateOf(false) + + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val coverMedia = mutableStateOf(null) + val audioMedia = mutableStateOf(null) + val pickedAudioName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(null) + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + private var dTag: String? = null + private var loadedEvent: Podcasting20EpisodeEvent? = null + + /** Carried across an edit so we don't drop the original publish date or value splits on save. */ + private var preservedPubDate: String? = null + private var preservedValue: PodcastValue? = 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() + preservedValue = existing.value() + title.value = existing.title().orEmpty() + description.value = existing.description().orEmpty() + audioUrl.value = + existing + .audios() + .firstOrNull() + ?.url + .orEmpty() + coverUrl.value = existing.image().orEmpty() + durationSeconds.value = existing.durationInSeconds()?.toString().orEmpty() + episodeNumber.value = existing.number()?.toString().orEmpty() + season.value = existing.season()?.toString().orEmpty() + videoUrl.value = existing.video()?.url.orEmpty() + transcriptUrl.value = existing.transcriptUrl().orEmpty() + chaptersUrl.value = existing.chaptersUrl().orEmpty() + topics.value = existing.topics().joinToString(", ") + } + } + } + + fun setPickedCover(uris: ImmutableList) { + coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun clearPickedCover() { + coverMedia.value = null + coverUrl.value = "" + } + + fun setPickedAudio( + context: Context, + uri: SelectedMedia?, + ) { + if (uri == null) { + audioMedia.value = null + pickedAudioName.value = null + return + } + audioMedia.value = MultiOrchestrator(persistentListOf(uri)) + pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/') + + val appContext = context.applicationContext + viewModelScope.launch(Dispatchers.IO) { + val probed = PodcastComposerMedia.probeAudio(appContext, uri.uri) ?: return@launch + withContext(Dispatchers.Main.immediate) { + probed.durationSeconds?.let { durationSeconds.value = it.toString() } + if (title.value.isBlank()) probed.title?.let { title.value = it } + } + } + } + + fun clearPickedAudio() { + audioMedia.value = null + pickedAudioName.value = null + } + + /** Valid with a title and a resolvable audio source (picked file or a pasted URL). */ + fun isValid(): Boolean = title.value.isNotBlank() && (audioMedia.value != null || audioUrl.value.isNotBlank()) + + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return + + val server = selectedServer.value + if (server == null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + + val snapshot = + Snapshot( + title = title.value.trim(), + description = description.value.trim().ifBlank { null }, + durationSeconds = durationSeconds.value.trim().toLongOrNull(), + episodeNumber = episodeNumber.value.trim().toIntOrNull(), + season = season.value.trim().toIntOrNull(), + videoUrl = videoUrl.value.trim().ifBlank { null }, + transcriptUrl = transcriptUrl.value.trim().ifBlank { null }, + chaptersUrl = chaptersUrl.value.trim().ifBlank { null }, + topics = PodcastComposerMedia.parseCsv(topics.value), + coverOrchestrator = coverMedia.value, + audioOrchestrator = audioMedia.value, + existingCoverUrl = coverUrl.value.trim().ifBlank { null }, + existingAudioUrl = audioUrl.value.trim(), + server = server, + quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value), + stripMetadata = stripMetadata.value, + appContext = context.applicationContext, + ) + + isSending.value = true + + accountViewModel.launchSigner { + try { + val (newCoverUrl, newAudioUrl) = performParallelUploads(snapshot) + val finalCoverUrl = newCoverUrl ?: snapshot.existingCoverUrl + val finalAudioUrl = newAudioUrl ?: snapshot.existingAudioUrl + if (finalAudioUrl.isBlank()) { + accountViewModel.toastManager.toast( + "Audio upload failed", + "No audio URL ended up available for the published episode.", + ) + return@launchSigner + } + + publishEpisode(snapshot, finalCoverUrl, finalAudioUrl) + + account.settings.changeDefaultFileServer(snapshot.server) + account.settings.changeStripLocationOnUpload(snapshot.stripMetadata) + + withContext(Dispatchers.Main.immediate) { + coverMedia.value = null + audioMedia.value = null + pickedAudioName.value = null + if (newCoverUrl != null) coverUrl.value = newCoverUrl + if (newAudioUrl != null) audioUrl.value = newAudioUrl + } + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast( + "Failed to publish episode", + t.message ?: t.javaClass.simpleName, + ) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } + + private class Snapshot( + val title: String, + val description: String?, + val durationSeconds: Long?, + val episodeNumber: Int?, + val season: Int?, + val videoUrl: String?, + val transcriptUrl: String?, + val chaptersUrl: String?, + val topics: List, + val coverOrchestrator: MultiOrchestrator?, + val audioOrchestrator: MultiOrchestrator?, + val existingCoverUrl: String?, + val existingAudioUrl: String, + val server: ServerName, + val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality, + val stripMetadata: Boolean, + val appContext: Context, + ) + + private suspend fun performParallelUploads(snapshot: Snapshot): Pair = + coroutineScope { + val deferreds = + listOf( + async { snapshot.coverOrchestrator?.let { uploadOne(it, "cover", snapshot) } }, + async { snapshot.audioOrchestrator?.let { uploadOne(it, "audio", snapshot) } }, + ) + val results = deferreds.awaitAll() + results[0] to results[1] + } + + private suspend fun uploadOne( + orchestrator: MultiOrchestrator, + kind: String, + snapshot: Snapshot, + ): String = + PodcastComposerMedia.upload( + orchestrator = orchestrator, + kind = kind, + account = account, + server = snapshot.server, + quality = snapshot.quality, + stripMetadata = snapshot.stripMetadata, + alt = snapshot.title.ifBlank { null }, + context = snapshot.appContext, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + + private suspend fun publishEpisode( + snapshot: Snapshot, + coverUrl: String?, + audioUrl: String, + ) { + // Audio/video MIME isn't tracked once uploaded (the URL is enough; the player sniffs the + // type). The summary lives in the `description` tag; `content` stays empty so the episode + // renderer doesn't show the same text twice (it renders the tag and the markdown body apart). + val template = + Podcasting20EpisodeEvent.build( + dTag = dTag ?: PodcastComposerMedia.generateDTag("episode"), + title = snapshot.title, + audios = listOf(PodcastAudio(audioUrl, null)), + pubdate = preservedPubDate ?: PodcastComposerMedia.rfc2822Now(), + description = snapshot.description, + image = coverUrl?.ifBlank { null }, + durationInSeconds = snapshot.durationSeconds, + video = snapshot.videoUrl?.let { PodcastAudio(it, null) }, + episodeNumber = snapshot.episodeNumber, + season = snapshot.season, + transcriptUrl = snapshot.transcriptUrl, + chaptersUrl = snapshot.chaptersUrl, + value = preservedValue, + topics = snapshot.topics, + ) + account.signAndComputeBroadcast(template) + } + + suspend fun deleteLoaded(): Boolean { + val target = loadedEvent ?: return false + val note = LocalCache.getOrCreateAddressableNote(target.address()) + account.delete(note) + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt new file mode 100644 index 0000000000..d7f7312460 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder +import com.vitorpamplona.amethyst.ui.stringRes + +/** Composer for a Podcasting-2.0 trailer (`kind:30055`): title, a short audio/video clip, and season. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewPodcastTrailerScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewPodcastTrailerViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(accountViewModel) { vm.init(accountViewModel) } + StrippingFailureDialog(vm.strippingFailureConfirmation) + + var wantsToPick by remember { mutableStateOf(false) } + if (wantsToPick) { + MediaFileSelect { picked -> + wantsToPick = false + vm.setPickedMedia(picked) + } + } + + val isBusy = vm.isSending.value + LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } } + + Scaffold( + topBar = { + SendingTopBar( + titleRes = R.string.podcast_new_trailer, + onCancel = { nav.popBack() }, + isActive = { vm.isValid() && !isBusy }, + onPost = { + if (!vm.isValid() || isBusy) return@SendingTopBar + vm.saveAndPublish(context, accountViewModel) + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner) + + val pickedName = vm.pickedName.value + if (pickedName != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp)) + .let { if (!isBusy) it.clickable { wantsToPick = true } else it } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + text = stringRes(R.string.podcast_episode_audio_picked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!isBusy) { + TextButton(onClick = { vm.clearPickedMedia() }) { Text(stringRes(R.string.cancel)) } + } + } + } else { + UploadPlaceholder( + iconSymbol = MaterialSymbols.MusicNote, + ctaRes = R.string.podcast_trailer_upload_cta, + hintRes = R.string.podcast_trailer_upload_hint, + onClick = { wantsToPick = true }, + aspectRatio = null, + enabled = !isBusy, + ) + } + + OutlinedTextField( + value = vm.title.value, + onValueChange = { vm.title.value = it }, + label = { Text(stringRes(R.string.podcast_episode_title_label)) }, + placeholder = { Text(stringRes(R.string.podcast_trailer_title_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + isError = vm.title.value.isBlank(), + ) + + OutlinedTextField( + value = vm.url.value, + onValueChange = { vm.url.value = it }, + label = { Text(stringRes(R.string.podcast_trailer_url_label)) }, + placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + + OutlinedTextField( + value = vm.season.value, + onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_episode_season_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + } +} + +// Audio or video file via OpenDocument. +@Composable +private fun MediaFileSelect(onPicked: (SelectedMedia?) -> Unit) { + val resolver = LocalContext.current.contentResolver + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) }, + ) + LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*", "video/*")) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt new file mode 100644 index 0000000000..1e6483d78b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext + +/** + * Composer for a Podcasting-2.0 trailer (`kind:30055`, addressable). A short preview clip: a title, + * one audio/video file (uploaded or pasted as a URL), and an optional season number. Always + * create-new — each trailer gets a fresh `d` tag. + */ +class NewPodcastTrailerViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val url = mutableStateOf("") + val season = mutableStateOf("") + val pickedMimeType = mutableStateOf(null) + + val isSending = mutableStateOf(false) + + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val media = mutableStateOf(null) + val pickedName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(null) + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + fun init(accountViewModel: AccountViewModel) { + if (::account.isInitialized) return + this.account = accountViewModel.account + this.selectedServer.value = account.settings.defaultFileServer + this.stripMetadata.value = account.settings.stripLocationOnUpload + } + + fun setPickedMedia(uri: SelectedMedia?) { + if (uri == null) { + media.value = null + pickedName.value = null + pickedMimeType.value = null + return + } + media.value = MultiOrchestrator(persistentListOf(uri)) + pickedName.value = uri.uri.lastPathSegment?.substringAfterLast('/') + pickedMimeType.value = uri.mimeType + } + + fun clearPickedMedia() { + media.value = null + pickedName.value = null + pickedMimeType.value = null + } + + fun isValid(): Boolean = title.value.isNotBlank() && (media.value != null || url.value.isNotBlank()) + + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return + val server = selectedServer.value + if (server == null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + + val titleSnap = title.value.trim() + val urlSnap = url.value.trim() + val seasonSnap = season.value.trim().toIntOrNull() + val mimeSnap = pickedMimeType.value + val mediaSnap = media.value + val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value) + val strip = stripMetadata.value + val appContext = context.applicationContext + + isSending.value = true + accountViewModel.launchSigner { + try { + val finalUrl = + if (mediaSnap != null) { + PodcastComposerMedia.upload( + orchestrator = mediaSnap, + kind = "trailer", + account = account, + server = server, + quality = quality, + stripMetadata = strip, + alt = titleSnap.ifBlank { null }, + context = appContext, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + } else { + urlSnap + } + if (finalUrl.isBlank()) { + accountViewModel.toastManager.toast("Trailer upload failed", "No URL was available for the trailer.") + return@launchSigner + } + + val template = + Podcasting20TrailerEvent.build( + dTag = PodcastComposerMedia.generateDTag("trailer"), + title = titleSnap, + url = finalUrl, + pubdate = PodcastComposerMedia.rfc2822Now(), + mimeType = mimeSnap, + season = seasonSnap, + ) + account.signAndComputeBroadcast(template) + + account.settings.changeDefaultFileServer(server) + account.settings.changeStripLocationOnUpload(strip) + + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast("Failed to publish trailer", t.message ?: t.javaClass.simpleName) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt new file mode 100644 index 0000000000..ac3e4bcf34 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt @@ -0,0 +1,297 @@ +/* + * 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.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.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 + + // Bump on each resume so returning from a composer re-scans LocalCache. + var refresh by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + refresh++ + onPauseOrDispose { } + } + + val show = + remember(refresh) { + val address = Address(AppSpecificDataEvent.KIND, me, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) + (LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) } + } + + val episodes = + remember(refresh) { + LocalCache.addressables + .filterIntoSet { _, note -> + val e = note.event + e is Podcasting20EpisodeEvent && e.pubKey == me + }.mapNotNull { it.event as? Podcasting20EpisodeEvent } + .sortedByDescending { it.createdAt } + } + + val trailers = + remember(refresh) { + LocalCache.addressables + .filterIntoSet { _, note -> + val e = note.event + e is Podcasting20TrailerEvent && e.pubKey == me + }.mapNotNull { it.event as? Podcasting20TrailerEvent } + .sortedByDescending { it.createdAt } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.podcast_your_podcast), nav) }, + ) { pad -> + LazyColumn( + modifier = Modifier.padding(pad).fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ShowHeaderCard(show, onEdit = { nav.nav(Route.EditPodcastShow) }) } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = { nav.nav(Route.NewPodcastEpisode()) }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(text = stringRes(R.string.podcast_new_episode), modifier = Modifier.padding(start = 6.dp)) + } + OutlinedButton( + onClick = { nav.nav(Route.NewPodcastTrailer) }, + modifier = Modifier.weight(1f), + ) { + Text(text = stringRes(R.string.podcast_new_trailer)) + } + } + } + + if (episodes.isNotEmpty()) { + item { SectionHeader(pluralStringResource(R.plurals.podcast_episode_count, episodes.size, episodes.size)) } + items(episodes, key = { it.id }) { ep -> + EpisodeRow( + title = ep.title() ?: stringRes(R.string.podcast_untitled), + subtitle = episodeSubtitle(ep), + onClick = { nav.nav(Route.NewPodcastEpisode(ep.dTag())) }, + ) + } + } + + if (trailers.isNotEmpty()) { + item { SectionHeader(pluralStringResource(R.plurals.podcast_trailer_count, trailers.size, trailers.size)) } + items(trailers, key = { it.id }) { tr -> + EpisodeRow( + title = tr.title() ?: stringRes(R.string.podcast_untitled), + subtitle = tr.url(), + onClick = null, + ) + } + } + + if (episodes.isEmpty()) { + item { + Text( + text = stringRes(R.string.podcast_no_episodes_yet), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun ShowHeaderCard( + show: Podcasting20PodcastMetadata?, + onEdit: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onEdit) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + val image = show?.showImage() + Box( + modifier = Modifier.size(64.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + if (!image.isNullOrBlank()) { + Image( + painter = rememberAsyncImagePainter(model = image), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } else { + Icon(symbol = MaterialSymbols.Podcasts, contentDescription = null, modifier = Modifier.size(30.dp)) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = show?.showTitle()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.podcast_create_your_show), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (show != null) stringRes(R.string.podcast_tap_to_edit_show) else stringRes(R.string.podcast_create_show_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Icon(symbol = MaterialSymbols.Edit, contentDescription = null, modifier = Modifier.size(20.dp)) + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp), + ) +} + +@Composable +private fun EpisodeRow( + title: String, + subtitle: String?, + onClick: (() -> Unit)?, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onClick != null) { + Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, modifier = Modifier.size(20.dp), tint = Color.Gray) + } + } +} + +private fun episodeSubtitle(ep: Podcasting20EpisodeEvent): String? { + val season = ep.season() + val number = ep.number() + return when { + season != null && number != null -> "S$season · E$number" + number != null -> "Ep $number" + season != null -> "Season $season" + else -> null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt new file mode 100644 index 0000000000..d034d20271 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** + * Shared upload + media-probe mechanics for the Podcasting-2.0 composers (episode, show, trailer). + * Mirrors the music-track composer's approach but kept in one place so all three authoring + * ViewModels resolve a picked file to a hosted URL the same way. + */ +object PodcastComposerMedia { + class UploadException( + kind: String, + details: String, + ) : Exception("$kind upload failed: $details") + + /** + * Uploads a single picked file (cover image or audio) through the user's media server and + * returns the hosted URL. Throws [UploadException] if the server rejects it or returns no URL. + */ + suspend fun upload( + orchestrator: MultiOrchestrator, + kind: String, + account: Account, + server: ServerName, + quality: CompressorQuality, + stripMetadata: Boolean, + alt: String?, + context: Context, + onStrippingFailed: suspend () -> Boolean, + ): String { + val res = + orchestrator.upload( + alt = alt, + contentWarningReason = null, + mediaQuality = quality, + server = server, + account = account, + context = context, + useH265 = false, + stripMetadata = stripMetadata, + onStrippingFailed = onStrippingFailed, + ) + if (!res.allGood) throw UploadException(kind, formatUploadErrors(res.errors, context)) + return firstUploadedUrl(res.successful) + ?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.") + } + + private fun firstUploadedUrl(successful: List): String? = + successful + .firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult } + ?.url + + private fun formatUploadErrors( + errors: List, + context: Context, + ): String = + errors + .map { context.getString(it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + + /** Audio metadata read off a picked file, used to auto-fill the composer. Any field may be null. */ + class ProbedAudio( + val durationSeconds: Int?, + val title: String?, + ) + + /** + * Reads duration + title from a picked audio file via [MediaMetadataRetriever] (heavy — call off + * the main thread). Returns null if the provider rejects it or the file isn't a real container. + */ + fun probeAudio( + context: Context, + uri: Uri, + ): ProbedAudio? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(context, uri) + val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() + ProbedAudio( + durationSeconds = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } }, + title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null }, + ) + } catch (_: Exception) { + null + } finally { + retriever.release() + } + } + + /** Current time as an RFC2822 date string (`Tue, 24 Jun 2025 12:00:00 GMT`) — the spec's `pubdate`. */ + fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT"))) + + /** A fresh, stable `d` tag for a new addressable episode/trailer. */ + fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}" + + /** Splits a comma-separated text field into a clean list (trimmed, no blanks). */ + fun parseCsv(text: String): List = text.split(',').map { it.trim() }.filter { it.isNotEmpty() } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 91c31f3fc5..ab1c969588 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -943,6 +943,67 @@ Sends value automatically while you listen. Streamed %1$d sats this session Connect a Nostr Wallet Connect or debit wallet to stream sats while listening. + New episode + Edit episode + Publishing… + Add cover art + Square image shown for the episode + Title + Episode title + Summary + Duration (seconds) + More details + Season + Episode # + Video URL + Transcript URL + Chapters URL + Topics + comma, separated, tags + Audio URL + https://…/episode.mp3 + Audio file ready to upload + Add audio file + MP3, M4A, or other audio + Delete episode + Delete this episode? This can\'t be undone. + Your podcast + Add cover art + Square artwork for your show + Show title + My Podcast + Description + Author + Contact email + Website + Categories + Technology, News + Funding links + https://… + Language + en + Copyright + Show type + Episodic + Serial + Explicit content + Show complete (no more episodes) + Locked (premium) + New trailer + Add trailer clip + Short audio or video preview + Trailer title + Media URL + Your podcast + Untitled + No episodes yet. Tap New episode to publish your first one. + Create your podcast + Tap to edit show details + Set up your show\'s title, art, and details + + %1$d trailer + %1$d trailers + %1$d episode %1$d episodes From 6db98c2a73ebe284dff85f1d556be000523bd7a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:17:02 +0000 Subject: [PATCH 20/39] feat: back the podcast authoring hub with a dedicated REQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Your podcast" hub previously listed only what happened to be in LocalCache, so on a fresh install (or before the feed had loaded the creator's events) it could show an empty or stale catalog. Add a MyPodcast subscription (mirrors the OnePodcast assembler trio) that, while the hub is on screen, keeps a REQ open for the creator's OWN Podcasting-2.0 catalog on their outbox relays: the addressable episodes (30054) and trailers (30055) by author, plus the show-metadata kind:30078 constrained to #d=["podcast-metadata"] (reusing the existing constant so the overloaded app-data kind isn't pulled wholesale). Registered in RelaySubscriptionsCoordinator. The hub now also reacts to LocalCache.live.newEventBundles — when the creator's own episodes/trailers/metadata arrive over the REQ, the lists refresh in place rather than only on resume. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../RelaySubscriptionsCoordinator.kt | 3 + .../authoring/PodcastAuthoringScreen.kt | 24 ++++++- .../podcasts/datasource/FilterMyPodcast.kt | 69 +++++++++++++++++++ .../datasource/MyPodcastFeedSubAssembler.kt | 38 ++++++++++ .../datasource/MyPodcastFilterAssembler.kt | 46 +++++++++++++ .../MyPodcastFilterAssemblerSubscription.kt | 41 +++++++++++ 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9cbfccfa28..e6eff8162c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -60,6 +60,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 @@ -144,6 +145,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 nsites = NsitesFilterAssembler(client) @@ -196,6 +198,7 @@ class RelaySubscriptionsCoordinator( podcastEpisodes, podcasts, onePodcast, + myPodcast, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt index ac3e4bcf34..ee6ee19595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt @@ -40,6 +40,7 @@ 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 @@ -63,6 +64,7 @@ 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 @@ -85,12 +87,32 @@ fun PodcastAuthoringScreen( ) { val me = accountViewModel.account.userProfile().pubkeyHex - // Bump on each resume so returning from a composer re-scans LocalCache. + // 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt new file mode 100644 index 0000000000..28c246e084 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +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_METADATA_D_FILTER +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent + +// What the authoring hub needs about the logged-in creator's OWN Podcasting-2.0 catalog: +// the addressable episodes (kind 30054) and trailers (kind 30055)... +private val MyPodcastFeedKinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND) + +/** + * 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]). Two filters: the addressable episodes/trailers by author, and the show-metadata + * `kind:30078` constrained to `#d=["podcast-metadata"]` (that kind is overloaded, so the constraint + * keeps the REQ from pulling every app's NIP-78 data). Queried on the creator's own outbox relays. + */ +fun filterMyPodcast( + user: User, + since: SincePerRelayMap?, +): List { + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) + + val authors = setOf(user.pubkeyHex) + + return relays.flatMap { relay -> + filterPodcastEventsByAuthors( + relay = relay, + kinds = MyPodcastFeedKinds, + authors = authors, + since = since?.get(relay)?.time, + ) + + filterPodcastEventsByAuthors( + relay = relay, + kinds = PODCASTING20_METADATA_KINDS, + authors = authors, + since = since?.get(relay)?.time, + additionalTags = PODCAST_METADATA_D_FILTER, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt new file mode 100644 index 0000000000..d9ce4aba05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class MyPodcastFeedSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: MyPodcastQueryState, + since: SincePerRelayMap?, + ): List = filterMyPodcast(user(key), since) + + override fun user(key: MyPodcastQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt new file mode 100644 index 0000000000..443d114108 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +// Keyed by the logged-in creator's own pubkey. Drives the authoring hub's REQ for the creator's +// own Podcasting-2.0 catalog (episodes, trailers, and the show-metadata event). +class MyPodcastQueryState( + val user: User, +) + +class MyPodcastFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + MyPodcastFeedSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..9fd9240b5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * While the authoring hub is on screen, keeps a REQ open for the logged-in creator's own + * Podcasting-2.0 catalog (episodes, trailers, and show metadata), so the hub fills in even when the + * events weren't already cached. + */ +@Composable +fun MyPodcastFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + val state = + remember(accountViewModel) { + MyPodcastQueryState(accountViewModel.account.userProfile()) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().myPodcast) +} From cd73020dd8fea43f296200f3c46f4748f3bfcf89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:29:00 +0000 Subject: [PATCH 21/39] feat: V4V split editor in the podcast show and episode composers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a creator define their value-for-value splits in-app, closing the create→get-paid loop: set up recipients on the show (and override per episode), publish, and listeners' boosts/streams fan out to those destinations. Adds a reusable V4VSplitEditorState + V4VSplitEditor composable: a card with one row per recipient (name, Lightning-address vs node/keysend toggle, address, weight, optional fee) and an "Add recipient" action, showing each recipient's live percentage of the total weight. toPodcastValue() rebuilds the PodcastValue on save (null when there are no payable recipients); a loaded block's suggested amount/currency/enabled are carried through untouched. Wired into both composers, replacing the previous preserve-only passthrough: - Show editor: edits the show-level split (kind:30078 value block). - Episode editor: edits the episode-level override (in More details). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../authoring/EditPodcastShowScreen.kt | 2 + .../authoring/EditPodcastShowViewModel.kt | 9 +- .../authoring/NewPodcastEpisodeScreen.kt | 3 + .../authoring/NewPodcastEpisodeViewModel.kt | 12 +- .../podcasts/authoring/V4VSplitEditor.kt | 207 ++++++++++++++++++ .../podcasts/authoring/V4VSplitEditorState.kt | 108 +++++++++ amethyst/src/main/res/values/strings.xml | 12 + 7 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt index 7cdfb9ad8e..ebfa77b3bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -169,6 +169,8 @@ fun EditPodcastShowScreen( SwitchRow(stringRes(R.string.podcast_show_explicit), vm.explicit.value) { vm.explicit.value = it } SwitchRow(stringRes(R.string.podcast_show_complete), vm.complete.value) { vm.complete.value = it } SwitchRow(stringRes(R.string.podcast_show_locked), vm.locked.value) { vm.locked.value = it } + + V4VSplitEditor(vm.splitEditor) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt index 1c82ce3b88..d45f9b922f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata -import com.vitorpamplona.quartz.podcasts.PodcastValue import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -82,9 +81,11 @@ class EditPodcastShowViewModel : ViewModel() { val mediaQualitySlider = mutableStateOf(1) val stripMetadata = mutableStateOf(true) + /** Editable value-for-value split for the show. */ + val splitEditor = V4VSplitEditorState() + /** Fields the editor doesn't surface but must not drop on save. */ private var preservedGuid: String? = null - private var preservedValue: PodcastValue? = null private var hasExisting = false fun init(accountViewModel: AccountViewModel) { @@ -98,7 +99,7 @@ class EditPodcastShowViewModel : ViewModel() { if (existing != null) { hasExisting = true preservedGuid = existing.guid() - preservedValue = existing.showValue() + splitEditor.load(existing.showValue()) title.value = existing.showTitle().orEmpty() description.value = existing.showDescription().orEmpty() author.value = existing.showAuthor().orEmpty() @@ -162,7 +163,7 @@ class EditPodcastShowViewModel : ViewModel() { type = type.value.trim().ifBlank { null }, complete = complete.value.takeIf { it }, guid = preservedGuid, - value = preservedValue, + value = splitEditor.toPodcastValue(), ), coverOrchestrator = coverOrch, server = server, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt index 1b593d324a..9741a4f522 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -250,6 +250,9 @@ fun NewPodcastEpisodeScreen( singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), ) + + // Episode-level V4V override; leave empty to inherit the show's split. + V4VSplitEditor(vm.splitEditor) } if (vm.isEditing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt index 0ac2b4f3bc..d2309ea701 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt @@ -88,9 +88,11 @@ class NewPodcastEpisodeViewModel : ViewModel() { private var dTag: String? = null private var loadedEvent: Podcasting20EpisodeEvent? = null - /** Carried across an edit so we don't drop the original publish date or value splits on save. */ + /** Editable value-for-value split for this episode (overrides the show's split). */ + val splitEditor = V4VSplitEditorState() + + /** Carried across an edit so we don't drop the original publish date on save. */ private var preservedPubDate: String? = null - private var preservedValue: PodcastValue? = null val isEditing: Boolean get() = loadedEvent != null @@ -110,7 +112,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { dTag = editDTag loadedEvent = existing preservedPubDate = existing.pubDate() - preservedValue = existing.value() + splitEditor.load(existing.value()) title.value = existing.title().orEmpty() description.value = existing.description().orEmpty() audioUrl.value = @@ -196,6 +198,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { transcriptUrl = transcriptUrl.value.trim().ifBlank { null }, chaptersUrl = chaptersUrl.value.trim().ifBlank { null }, topics = PodcastComposerMedia.parseCsv(topics.value), + value = splitEditor.toPodcastValue(), coverOrchestrator = coverMedia.value, audioOrchestrator = audioMedia.value, existingCoverUrl = coverUrl.value.trim().ifBlank { null }, @@ -255,6 +258,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { val transcriptUrl: String?, val chaptersUrl: String?, val topics: List, + val value: PodcastValue?, val coverOrchestrator: MultiOrchestrator?, val audioOrchestrator: MultiOrchestrator?, val existingCoverUrl: String?, @@ -315,7 +319,7 @@ class NewPodcastEpisodeViewModel : ViewModel() { season = snapshot.season, transcriptUrl = snapshot.transcriptUrl, chaptersUrl = snapshot.chaptersUrl, - value = preservedValue, + value = snapshot.value, topics = snapshot.topics, ) account.signAndComputeBroadcast(template) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt new file mode 100644 index 0000000000..6b8057c8f3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.grayText + +/** + * Editor for a Podcasting-2.0 value-for-value split: a card listing each recipient (name, lnaddress + * vs node toggle, address, weight, optional fee) plus an "Add recipient" action. Each recipient's + * share of incoming sats is shown live as a percentage of the total weight. Drives a + * [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue] on save. + */ +@Composable +fun V4VSplitEditor(state: V4VSplitEditorState) { + val total = state.totalSplit() + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.podcast_value_for_value), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (state.recipients.isEmpty()) { + Text( + text = stringRes(R.string.podcast_value_editor_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + + state.recipients.forEach { draft -> + RecipientCard( + draft = draft, + total = total, + onRemove = { state.remove(draft) }, + ) + } + + TextButton(onClick = { state.add() }, modifier = Modifier.fillMaxWidth()) { + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(text = stringRes(R.string.podcast_value_add_recipient), modifier = Modifier.padding(start = 6.dp)) + } + } +} + +@Composable +private fun RecipientCard( + draft: RecipientDraft, + total: Int, + onRemove: () -> Unit, +) { + val isNode by draft.isNode + val weight = + draft.split.value + .trim() + .toIntOrNull() ?: 0 + val percent = if (total > 0 && weight > 0) weight * 100 / total else 0 + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(R.string.podcast_value_remove_recipient), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + OutlinedTextField( + value = draft.name.value, + onValueChange = { draft.name.value = it }, + label = { Text(stringRes(R.string.podcast_value_recipient_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = !isNode, + onClick = { draft.isNode.value = false }, + label = { Text(stringRes(R.string.podcast_value_type_lnaddress)) }, + ) + FilterChip( + selected = isNode, + onClick = { draft.isNode.value = true }, + label = { Text(stringRes(R.string.podcast_value_type_node)) }, + ) + } + + OutlinedTextField( + value = draft.address.value, + onValueChange = { draft.address.value = it }, + label = { + Text( + if (isNode) { + stringRes(R.string.podcast_value_node_pubkey) + } else { + stringRes(R.string.podcast_value_lnaddress) + }, + ) + }, + placeholder = { + Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = draft.address.value.isBlank(), + ) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft.split.value, + onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_value_weight)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = weight <= 0, + ) + FilterChip( + selected = draft.fee.value, + onClick = { draft.fee.value = !draft.fee.value }, + label = { Text(stringRes(R.string.podcast_value_fee)) }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt new file mode 100644 index 0000000000..7177854346 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient + +/** + * Editable state for a Podcasting-2.0 value-for-value (V4V) split, shared by the show and episode + * composers. Holds one [RecipientDraft] per payee; [toPodcastValue] turns the drafts back into a + * [PodcastValue] on save (or null when there are no payable recipients). The suggested amount / + * currency / enabled flag on a loaded block are carried through untouched — the editor only manages + * the recipient list. + */ +class V4VSplitEditorState { + val recipients = mutableStateListOf() + + private var amount: Long? = null + private var currency: String? = null + private var enabled: Boolean? = null + + fun load(value: PodcastValue?) { + recipients.clear() + amount = value?.amount + currency = value?.currency + enabled = value?.enabled + value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) } + } + + fun add() { + recipients.add(RecipientDraft()) + } + + fun remove(draft: RecipientDraft) { + recipients.remove(draft) + } + + /** Sum of the weights of the payable recipients — used to show each as a percentage. */ + fun totalSplit(): Int = recipients.mapNotNull { it.toRecipient() }.sumOf { it.split } + + fun toPodcastValue(): PodcastValue? { + val valid = recipients.mapNotNull { it.toRecipient() } + if (valid.isEmpty()) return null + return PodcastValue( + enabled = enabled, + amount = amount, + currency = currency, + recipients = valid, + ) + } +} + +/** One editable recipient row. All fields are Compose state so the editor recomposes as they change. */ +class RecipientDraft { + val name = mutableStateOf("") + + /** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */ + val isNode = mutableStateOf(false) + val address = mutableStateOf("") + val split = mutableStateOf("1") + val fee = mutableStateOf(false) + + /** A recipient is payable once it has an address and a positive weight. */ + fun toRecipient(): PodcastValueRecipient? { + val addr = address.value.trim() + if (addr.isBlank()) return null + val weight = split.value.trim().toIntOrNull() ?: return null + if (weight <= 0) return null + return PodcastValueRecipient( + name = name.value.trim().ifBlank { null }, + type = if (isNode.value) PodcastValue.TYPE_NODE else PodcastValue.TYPE_LNADDRESS, + address = addr, + split = weight, + fee = if (fee.value) true else null, + ) + } + + companion object { + fun from(recipient: PodcastValueRecipient): RecipientDraft = + RecipientDraft().apply { + name.value = recipient.name.orEmpty() + isNode.value = recipient.type == PodcastValue.TYPE_NODE + address.value = recipient.address.orEmpty() + split.value = recipient.split.toString() + fee.value = recipient.fee == true + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ab1c969588..58d11807f3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1004,6 +1004,18 @@ %1$d trailer %1$d trailers + Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. + Add recipient + Remove recipient + Name (optional) + Lightning address + Node (keysend) + Lightning address + name@example.com + Node pubkey + 02abc… (33-byte hex) + Weight + Fee %1$d episode %1$d episodes From 32ac6853872bfcef02e39b9eec9dc78dbba58b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:56:38 +0000 Subject: [PATCH 22/39] feat: add Nostr users to V4V splits by search, with avatar + name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split editor previously only took raw, hand-typed Lightning addresses / node pubkeys — no Nostr users, no avatars, no search. Bring it up to the Amethyst standard used by zap-splits. Adding a recipient now leads with a user search (reusing UserSuggestionState + ShowUserSuggestionList): type a name or @handle, pick a person, and they're added rendered with their avatar (BaseUserPicture) and display name (UsernameDisplay), with their lud16 lightning address resolved automatically at save time. Picking a user with no Lightning address is rejected with a toast. A manual "Add address" fallback remains for raw destinations — a node pubkey for keysend, or a non-Nostr lightning address — which keep the type toggle + text field. Each recipient still shows its live percentage of the total weight and an optional fee flag. (Recipients loaded from an existing value block arrive as raw addresses, since the wire format stores only the Lightning destination.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../authoring/EditPodcastShowScreen.kt | 2 +- .../authoring/NewPodcastEpisodeScreen.kt | 2 +- .../podcasts/authoring/V4VSplitEditor.kt | 200 ++++++++++++++---- .../podcasts/authoring/V4VSplitEditorState.kt | 47 +++- amethyst/src/main/res/values/strings.xml | 4 + 5 files changed, 209 insertions(+), 46 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt index ebfa77b3bd..2917509791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -170,7 +170,7 @@ fun EditPodcastShowScreen( 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) + V4VSplitEditor(vm.splitEditor, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt index 9741a4f522..941a13c52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -252,7 +252,7 @@ fun NewPodcastEpisodeScreen( ) // Episode-level V4V override; leave empty to inherit the show's split. - V4VSplitEditor(vm.splitEditor) + V4VSplitEditor(vm.splitEditor, accountViewModel) } if (vm.isEditing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt index 6b8057c8f3..cf9f22fa05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -24,9 +24,12 @@ 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 @@ -37,6 +40,9 @@ 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 @@ -46,18 +52,32 @@ 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.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: a card listing each recipient (name, lnaddress - * vs node toggle, address, weight, optional fee) plus an "Add recipient" action. Each recipient's - * share of incoming sats is shown live as a percentage of the total weight. Drives a - * [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue] on save. + * 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) { +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 = @@ -92,27 +112,60 @@ fun V4VSplitEditor(state: V4VSplitEditorState) { } state.recipients.forEach { draft -> - RecipientCard( - draft = draft, - total = total, - onRemove = { state.remove(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, ) } - TextButton(onClick = { state.add() }, modifier = Modifier.fillMaxWidth()) { + // 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_recipient), modifier = Modifier.padding(start = 6.dp)) + Text(text = stringRes(R.string.podcast_value_add_address), modifier = Modifier.padding(start = 6.dp)) } } } @Composable -private fun RecipientCard( - draft: RecipientDraft, +private fun RecipientShell( total: Int, + draft: RecipientDraft, onRemove: () -> Unit, + content: @Composable RowScope.() -> Unit, ) { - val isNode by draft.isNode val weight = draft.split.value .trim() @@ -128,6 +181,75 @@ private fun RecipientCard( .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), @@ -170,15 +292,7 @@ private fun RecipientCard( 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) - }, - ) - }, + 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)) }, @@ -187,21 +301,29 @@ private fun RecipientCard( isError = draft.address.value.isBlank(), ) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = draft.split.value, - onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } }, - label = { Text(stringRes(R.string.podcast_value_weight)) }, - modifier = Modifier.weight(1f), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - isError = weight <= 0, - ) - FilterChip( - selected = draft.fee.value, - onClick = { draft.fee.value = !draft.fee.value }, - label = { Text(stringRes(R.string.podcast_value_fee)) }, - ) - } + WeightAndFeeRow(draft, weight) + } +} + +@Composable +private fun WeightAndFeeRow( + draft: RecipientDraft, + weight: Int, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft.split.value, + onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_value_weight)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = weight <= 0, + ) + FilterChip( + selected = draft.fee.value, + onClick = { draft.fee.value = !draft.fee.value }, + label = { Text(stringRes(R.string.podcast_value_fee)) }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt index 7177854346..99de92c0ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient @@ -47,10 +48,22 @@ class V4VSplitEditorState { value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) } } - fun add() { + /** 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) } @@ -70,8 +83,15 @@ class V4VSplitEditorState { } } -/** One editable recipient row. All fields are Compose state so the editor recomposes as they change. */ +/** + * One editable recipient row. Either backed by a Nostr [user] (rendered with avatar + name; its + * lightning address resolves at save time) or a raw destination typed by hand ([name]/[isNode]/ + * [address]). All fields are Compose state so the editor recomposes as they change. + */ class RecipientDraft { + /** When set, this row is a Nostr user — paid at their lud16, shown with avatar + name. */ + val user = mutableStateOf(null) + val name = mutableStateOf("") /** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */ @@ -80,12 +100,24 @@ class RecipientDraft { val split = mutableStateOf("1") val fee = mutableStateOf(false) - /** A recipient is payable once it has an address and a positive weight. */ + /** A recipient is payable once it resolves to an address and has a positive weight. */ fun toRecipient(): PodcastValueRecipient? { - val addr = address.value.trim() - if (addr.isBlank()) return null val weight = split.value.trim().toIntOrNull() ?: return null if (weight <= 0) return null + + 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, @@ -96,6 +128,11 @@ class RecipientDraft { } 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() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 58d11807f3..d8b363279b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1006,6 +1006,10 @@ Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. Add recipient + Add address manually + Add a Nostr user + Search by name or @handle + This user has no Lightning address Remove recipient Name (optional) Lightning address From 64567c7056e76d7a206d7d16997c90d7d5c48bc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 23:01:29 +0000 Subject: [PATCH 23/39] refactor: move V4VSplitEditorState to commons for cross-front-end reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The value-for-value split editor's state holder is pure snapshot state over quartz types + a commons User — no Account, LocalCache, AccountViewModel, or Android dependency — so per the commons architecture (state holders belong in commons, CLI-safe where practical) it moves to commons.podcasts. A future Desktop/iOS V4V editor can now drive the same state; the editor composable stays platform-side (it needs AccountViewModel + user search). This is the only podcast app-layer file that's free of amethyst-only foundations: the rest of the podcast UI / ViewModels / feed filters / subscriptions are coupled to AccountViewModel, LocalCache, Account, the per-user subscription framework, or Android media/upload — the same foundations every feature in the app shares, none of which live in commons — so they stay in amethyst (as does, for the same reason, the analogous music composer). The podcast protocol itself was already fully shared: all 50 quartz podcast files live in commonMain. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../podcasts/authoring/EditPodcastShowViewModel.kt | 1 + .../podcasts/authoring/NewPodcastEpisodeViewModel.kt | 1 + .../screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt | 2 ++ .../amethyst/commons/podcasts}/V4VSplitEditorState.kt | 7 +++++-- 4 files changed, 9 insertions(+), 2 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts}/V4VSplitEditorState.kt (95%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt index d45f9b922f..8543e72b5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt @@ -23,6 +23,7 @@ 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt index d2309ea701..1566ed11bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt @@ -24,6 +24,7 @@ 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt index cf9f22fa05..7a9c5648ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -52,6 +52,8 @@ 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt index 99de92c0ad..f218a4684c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt @@ -18,11 +18,11 @@ * 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 +package com.vitorpamplona.amethyst.commons.podcasts import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf -import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient @@ -32,6 +32,9 @@ import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient * [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() From 5dc09513f6b9b9a91925be3c387d56b0b34ec05e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 16:06:45 +0000 Subject: [PATCH 24/39] refactor: build the hub's REQ inline; round the create-podcast FAB - FilterMyPodcast now builds its two Filters directly instead of routing through the topNav-oriented filterPodcastEventsByAuthors helper: episodes+trailers by author, and the kind:30078 show metadata constrained to #d=["podcast-metadata"]. Same wire output, but the #d constraint and the reason for two filters are now visible at the call site rather than hidden behind a shared map parameter. - The "create a podcast" FAB on the Podcasts feed was using the Material3 default shape (a rounded square); set shape = CircleShape to match every other FAB in the app. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../loggedIn/podcasts/PodcastsScreen.kt | 2 + .../podcasts/datasource/FilterMyPodcast.kt | 58 +++++++++++-------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt index 2e06dad7e8..ec3685bbfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -20,6 +20,7 @@ */ 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 @@ -94,6 +95,7 @@ fun PodcastsScreen( FabBottomBarPadded(nav) { FloatingActionButton( onClick = { nav.nav(Route.PodcastAuthoring) }, + shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt index 28c246e084..8cd77217a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt @@ -23,23 +23,24 @@ 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.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_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.filterPodcastEventsByAuthors 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 -// What the authoring hub needs about the logged-in creator's OWN Podcasting-2.0 catalog: -// the addressable episodes (kind 30054) and trailers (kind 30055)... -private val MyPodcastFeedKinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND) - /** * 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]). Two filters: the addressable episodes/trailers by author, and the show-metadata - * `kind:30078` constrained to `#d=["podcast-metadata"]` (that kind is overloaded, so the constraint - * keeps the REQ from pulling every app's NIP-78 data). Queried on the creator's own outbox relays. + * [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, @@ -49,21 +50,32 @@ fun filterMyPodcast( user.outboxRelays()?.ifEmpty { null } ?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) - val authors = setOf(user.pubkeyHex) + val authors = listOf(user.pubkeyHex) return relays.flatMap { relay -> - filterPodcastEventsByAuthors( - relay = relay, - kinds = MyPodcastFeedKinds, - authors = authors, - since = since?.get(relay)?.time, - ) + - filterPodcastEventsByAuthors( + val sinceTime = since?.get(relay)?.time + listOf( + RelayBasedFilter( relay = relay, - kinds = PODCASTING20_METADATA_KINDS, - authors = authors, - since = since?.get(relay)?.time, - additionalTags = PODCAST_METADATA_D_FILTER, - ) + 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, + ), + ), + ) } } From 1eeda56b51fb6411ef5490533945daf623acdffd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 20:07:54 +0000 Subject: [PATCH 25/39] feat: add "Mine" to the Podcasts and Podcast Episodes feed filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two podcast feeds used kind3GlobalPeopleRoutes, which omits the "Mine" option — so a creator had no way to filter the feed down to their own published shows/episodes (and the default follow-list view is usually empty for podcasts, since NIP-F4 shows are their own keypairs you rarely kind:3-follow). Add a podcastRoutes catalog in TopNavFilterState that mirrors musicRoutes (content catalog + Mine + interests + mute list) and point both podcast top bars at it. "Mine" resolves to an AuthorsTopNavPerRelayFilterSet of the user's own pubkey, which the podcast SubAssemblyHelper already dispatches through filterPodcastEventsByAuthors — so selecting it actually fetches the creator's own catalog, not a dead option. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/screen/TopNavFilterState.kt | 23 +++++++++++++++++++ .../podcasts/PodcastEpisodesTopBar.kt | 7 +++--- .../loggedIn/podcasts/PodcastsTopBar.kt | 2 +- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 9e05c1d6a9..9a2b7b26af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -328,6 +328,24 @@ class TopNavFilterState( ) } + private val _podcastRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the + // podcasts + episodes screens can show only the user's own published shows/episodes. + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -408,6 +426,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + val podcastRoutes = + _podcastRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt index ca40ecb546..81c27cead6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt @@ -62,9 +62,10 @@ private fun PodcastEpisodesTopNavFilterBar( accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { - // Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3 - // Follows, Around Me, Global, people lists, interest sets, mute list. - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + // Same content-style catalog as Music — All Follows, Your Follows, kind3 Follows, + // Around Me, Global, Mine (the user's own published shows/episodes), people lists, + // interest sets, mute list. + val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle() FeedFilterSpinner( placeholderCode = listName, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt index 28dcbeb002..9022f588d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt @@ -62,7 +62,7 @@ private fun PodcastsTopNavFilterBar( accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle() FeedFilterSpinner( placeholderCode = listName, From 821e9bafdd29b9ea2240b968a5a77d5ebec648e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 20:50:33 +0000 Subject: [PATCH 26/39] fix: drop the "Mock Podcast" kind:10154 spam flood before consuming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone is flooding thousands of identical mock NIP-F4 show-metadata events. They share an exact fingerprint — title "Mock Podcast", description and content both "Headless test feed" — so match that and refuse to cache them. - PodcastMetadataEvent.isMockSpam() encodes the fingerprint (all three fields must match, so a real show sharing one field is never flagged); unit-tested. - LocalCache's PodcastMetadataEvent consume branch returns without storing when it matches, so the spam never reaches the cache, feeds, search, or the merged podcast list. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/model/LocalCache.kt | 7 +- .../metadata/PodcastMetadataEvent.kt | 13 ++++ .../nipF4Podcasts/PodcastMockSpamTest.kt | 64 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index b5a1d5596f..59fc275990 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3754,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 -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt index d2739b18eb..fa02e05ec0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt @@ -83,9 +83,22 @@ class PodcastMetadataEvent( */ fun claimedAuthors() = tags.mapNotNull(AuthorTag::parse) + /** + * Fingerprint of a known spam flood — thousands of identical headless-test "Mock Podcast" + * shows. Their structure is exactly `title="Mock Podcast"`, `description="Headless test feed"`, + * `content="Headless test feed"`. Matched so the client can drop them before consuming. + */ + fun isMockSpam(): Boolean = + content == MOCK_SPAM_CONTENT && + title() == MOCK_SPAM_TITLE && + description() == MOCK_SPAM_CONTENT + companion object { const val KIND = 10154 + private const val MOCK_SPAM_TITLE = "Mock Podcast" + private const val MOCK_SPAM_CONTENT = "Headless test feed" + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt new file mode 100644 index 0000000000..4013c30977 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Verifies the fingerprint used to drop the "Mock Podcast" spam flood, and that it spares real shows. */ +class PodcastMockSpamTest { + private fun event( + title: String?, + description: String?, + content: String, + ): PodcastMetadataEvent { + val tags = + buildList { + title?.let { add(arrayOf("title", it)) } + description?.let { add(arrayOf("description", it)) } + }.toTypedArray() + return PodcastMetadataEvent(EMPTY_ID, EMPTY_ID, 0, tags, content, EMPTY_ID) + } + + @Test + fun `the exact mock spam structure matches`() { + assertTrue(event("Mock Podcast", "Headless test feed", "Headless test feed").isMockSpam()) + } + + @Test + fun `real shows are not flagged`() { + // A genuine show that happens to share none of the three fields. + assertFalse(event("My Real Podcast", "A show about things", "Welcome!").isMockSpam()) + // Same title only — not enough. + assertFalse(event("Mock Podcast", "A different description", "Real content").isMockSpam()) + // Same content only — not enough. + assertFalse(event("Another Show", "Another desc", "Headless test feed").isMockSpam()) + // Missing tags entirely. + assertFalse(event(null, null, "Headless test feed").isMockSpam()) + } + + companion object { + private val EMPTY_ID: HexKey = "0".repeat(64) + } +} From a65e37b8f50d89fe0eec1c01d8239dc60df54620 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 22:05:37 +0000 Subject: [PATCH 27/39] feat(podcasts): render Podcasting-2.0 shows in detail + thread views Make the single-podcast detail screen spec-neutral so a Podcasting-2.0 show (kind 30078, d=podcast-metadata, e.g. "Soapbox Sessions") renders its real cover art and title instead of the default Amethyst banner and "Podcasts" fallback: - PodcastHeader/PodcastScreen now take a resolved PodcastShow? instead of a NIP-F4-only PodcastMetadataEvent?, resolving from either the kind 10154 (F4) or kind 30078 (P2.0) metadata event. - FilterOnePodcast adds a #d=podcast-metadata filter on kind 30078 so the P2.0 show metadata is actually fetched on deep-links/fresh loads. - NoteMaster (thread/full view) now dispatches Podcasting20EpisodeEvent, Podcasting20TrailerEvent, and the kind 30078 podcast-metadata variant to the shared podcast renderers, matching NoteCompose. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../screen/loggedIn/podcasts/PodcastHeader.kt | 23 ++++++--- .../screen/loggedIn/podcasts/PodcastScreen.kt | 51 ++++++++++++------- .../podcasts/datasource/FilterOnePodcast.kt | 37 ++++++++++---- .../loggedIn/threadview/ThreadFeedView.kt | 12 +++++ 4 files changed, 88 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 946eb6bf7d..31970e4661 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -50,28 +50,35 @@ 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 claimedAuthors = remember(metadataEvent) { metadataEvent?.claimedAuthors() ?: emptyList() } - val podcastPubkey = remember(metadataEvent) { metadataEvent?.pubKey } - 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index b599f33aef..493747c106 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -61,8 +61,13 @@ 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.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( @@ -71,10 +76,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( @@ -82,23 +95,27 @@ fun PodcastScreen( factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account), ) - PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav) + PodcastScreen(podcast, f4Note, p20Note, feedViewModel, accountViewModel, nav) } @Composable fun PodcastScreen( podcast: User, - metadataNote: Note, + f4Note: Note, + p20Note: Note, feedViewModel: OnePodcastFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { WatchLifecycleAndUpdateModel(feedViewModel) - // Fetches both the show metadata (kind 10154) and every episode (kind 54) authored by - // this podcast's key from its outbox relays. + // Fetches the show metadata (NIP-F4 kind 10154 / Podcasting-2.0 kind 30078) and every episode + // (kind 54 / kind 30054) + trailer authored by this podcast's key from its outbox relays. OnePodcastFilterAssemblerSubscription(podcast, accountViewModel) - val metadataEvent by observeNoteEvent(metadataNote, accountViewModel) + val f4Event by observeNoteEvent(f4Note, accountViewModel) + val p20Event by observeNoteEvent(p20Note, accountViewModel) + val show: PodcastShow? = remember(f4Event, p20Event) { resolvePodcastShow(f4Event) ?: resolvePodcastShow(p20Event) } + val metadataNote = if (f4Event != null) f4Note else p20Note DisappearingScaffold( isInvertedLayout = false, @@ -106,7 +123,7 @@ 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), @@ -121,7 +138,7 @@ fun PodcastScreen( SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState -> PodcastScreenBody( metadataNote = metadataNote, - metadataEvent = metadataEvent, + show = show, feedViewModel = feedViewModel, listState = listState, accountViewModel = accountViewModel, @@ -135,7 +152,7 @@ fun PodcastScreen( @Composable private fun PodcastScreenBody( metadataNote: Note, - metadataEvent: PodcastMetadataEvent?, + show: PodcastShow?, feedViewModel: OnePodcastFeedViewModel, listState: LazyListState, accountViewModel: AccountViewModel, @@ -145,20 +162,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() } @@ -169,7 +186,7 @@ private fun PodcastScreenBody( @Composable private fun PodcastEpisodesList( metadataNote: Note, - metadataEvent: PodcastMetadataEvent?, + show: PodcastShow?, loaded: FeedState.Loaded, listState: LazyListState, accountViewModel: AccountViewModel, @@ -184,7 +201,7 @@ private fun PodcastEpisodesList( item("header") { // The list mixes in trailers; the header count should reflect episodes only. val episodeCount = items.list.count { it.event !is Podcasting20TrailerEvent } - PodcastHeader(metadataNote, metadataEvent, episodeCount, accountViewModel, nav) + PodcastHeader(metadataNote, show, episodeCount, accountViewModel, nav) } itemsIndexed( @@ -208,7 +225,7 @@ private fun PodcastEpisodesList( @Composable private fun PodcastHeaderWithStatus( metadataNote: Note, - metadataEvent: PodcastMetadataEvent?, + show: PodcastShow?, listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, @@ -219,7 +236,7 @@ private fun PodcastHeaderWithStatus( contentPadding = rememberFeedContentPadding(FeedPadding), ) { item("header") { - PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav) + PodcastHeader(metadataNote, show, null, accountViewModel, nav) } item("status") { status() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt index 4fbed64bfc..6c061b11a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -25,9 +25,11 @@ 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 // A single-podcast screen fetches everything authored by the show's pubkey, across both drafts: @@ -51,16 +53,31 @@ fun filterOnePodcast( user.outboxRelays()?.ifEmpty { null } ?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) - return relays.map { relay -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = OnePodcastKinds, - authors = listOf(user.pubkeyHex), - limit = 500, - since = since?.get(relay)?.time, - ), + return relays.flatMap { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = OnePodcastKinds, + authors = listOf(user.pubkeyHex), + limit = 500, + since = since?.get(relay)?.time, + ), + ), + // Podcasting-2.0 show metadata rides on the generic NIP-78 app-data kind (30078), + // so it needs its own #d=podcast-metadata filter rather than a bare kind match. + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(AppSpecificDataEvent.KIND), + authors = listOf(user.pubkeyHex), + tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)), + limit = 1, + since = since?.get(relay)?.time, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index e2319e7d03..85c84cfa69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -220,6 +220,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes @@ -324,6 +325,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent @@ -341,6 +343,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -832,8 +837,15 @@ private fun FullBleedNoteCompose( RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is PodcastEpisodeEvent) { RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is Podcasting20EpisodeEvent) { + RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is Podcasting20TrailerEvent) { + PodcastTrailerListItem(baseNote, accountViewModel, nav) } else if (noteEvent is PodcastMetadataEvent) { RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AppSpecificDataEvent && noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) { + // kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a podcast card. + RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, From ec3b41aa340e933c02ed68222af48464f03822b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 22:45:48 +0000 Subject: [PATCH 28/39] feat(podcasts): pay V4V splits through the zap button as real zaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard zap button now detects a Podcasting-2.0 value-for-value block on a podcast note (episode or show) and pays that split instead of a plain author zap — so V4V becomes a first-class zap rather than a separate, no- receipt payment: - AccountViewModel.zap() intercepts a note carrying a value block and routes the chosen amount to the V4V split. Intercepting at zap() covers every entry point (one-tap, amount popup, custom dialog, polls) with no UI churn. - V4VPaymentHandler gains asZap/zapType: lnaddress shares now attach a NIP-57 zap request, so a Nostr-aware provider mints a zappable invoice and publishes a receipt (driving the zap button's icon/counter). Node shares stay keysend — no LNURL, so a receipt is impossible there by protocol. - Per-minute streaming pays with asZap=false so it doesn't publish a receipt every minute. - The dedicated "Send value" button is now redundant and removed; PodcastValueSplits becomes a pure recipient/percentage breakdown display next to the zap button. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/service/V4VPaymentHandler.kt | 52 ++++++++-- .../amethyst/ui/note/types/PodcastEpisode.kt | 8 +- .../amethyst/ui/note/types/PodcastMetadata.kt | 8 +- .../ui/note/types/PodcastValueSplits.kt | 97 +++---------------- .../ui/screen/loggedIn/AccountViewModel.kt | 59 ++++++++++- amethyst/src/main/res/values/strings.xml | 4 +- 6 files changed, 118 insertions(+), 110 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index 9a545e8aeb..6fa95cfe3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -32,6 +32,8 @@ 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 @@ -45,17 +47,20 @@ 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], but the recipients are raw Lightning destinations - * declared in the value block (not Nostr users), so there is no zap request and no zap receipt. Two - * recipient kinds are handled: + * 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. + * 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. Keysend is - * only available over NWC, so node recipients are skipped (with an error) when no NWC wallet is set - * up. + * 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, @@ -76,6 +81,8 @@ class V4VPaymentHandler( onError: (title: String, message: String) -> Unit, onProgress: (percent: Float) -> Unit, onPayInvoicesViaIntent: (invoices: List) -> Unit, + asZap: Boolean = false, + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, ) = withContext(Dispatchers.IO) { val shares = value.computeShares(totalMilliSats) if (shares.isEmpty()) { @@ -108,6 +115,9 @@ class V4VPaymentHandler( assembleInvoices( shares = lnAddressShares, message = boostagram.message.orEmpty(), + asZap = asZap, + zapType = zapType, + zappedNote = zappedNote, okHttpClient = okHttpClient, context = context, onError = onError, @@ -164,21 +174,47 @@ class V4VPaymentHandler( private suspend fun assembleInvoices( shares: List, message: String, + asZap: Boolean, + zapType: LnZapEvent.ZapType, + zappedNote: Note?, okHttpClient: (String) -> OkHttpClient, context: Context, onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, ): List { + // When paying as a zap, attach a NIP-57 request to each share so the recipient's LNURL + // provider mints a zappable invoice and publishes a receipt. The receipt is attributed to + // the zapped note (toUser = null) since a value-block lnaddress is a raw payee, not + // necessarily a Nostr identity. Send to the show/episode author's inbox so they see it. + val noteEvent = zappedNote?.event + val authorRelays = zappedNote?.author?.inboxRelays()?.toSet() ?: emptySet() + var progress = 0f return mapNotNullAsync(shares) { share: PodcastValueShare -> val lnAddress = share.recipient.address ?: return@mapNotNullAsync null try { + val nostrRequest = + if (asZap && noteEvent != null) { + account.createZapRequestFor( + event = noteEvent, + pollOption = null, + message = message, + zapType = zapType, + toUser = null, + additionalRelays = authorRelays, + amountMillisats = share.amountMilliSats, + lnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32), + ) + } else { + null + } + val invoice = LightningAddressResolver().lnAddressInvoice( lnAddress = lnAddress, milliSats = share.amountMilliSats, message = message, - nostrRequest = null, + nostrRequest = nostrRequest, okHttpClient = okHttpClient, onProgress = {}, context = context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index a00834012c..a8a7f33572 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -195,13 +195,7 @@ fun RenderPodcastEpisode( } value?.takeIf { !makeItShort }?.let { - PodcastValueSplits( - value = it, - note = note, - episodeName = title, - podcastName = null, - accountViewModel = accountViewModel, - ) + PodcastValueSplits(value = it) } markdown?.takeIf { !makeItShort }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index fb461c5907..6fa80e7a86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -176,13 +176,7 @@ fun RenderPodcastMetadata( } value?.takeIf { !makeItShort }?.let { - PodcastValueSplits( - value = it, - note = note, - episodeName = null, - podcastName = title, - accountViewModel = accountViewModel, - ) + PodcastValueSplits(value = it) } if (fundingUrls.isNotEmpty() && !makeItShort) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt index e25d8232c0..f698555e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt @@ -22,35 +22,24 @@ 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.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton 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.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.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.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.Note -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 @@ -58,17 +47,16 @@ 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 - * "Send value" button (amount picker that fires the weighted Lightning split via - * [AccountViewModel.payV4V]), and one row per recipient (name/address + its share of the split). + * 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, - note: Note, - episodeName: String?, - podcastName: String?, - accountViewModel: AccountViewModel, -) { +fun PodcastValueSplits(value: PodcastValue) { val recipients = value.recipients.filter { it.split > 0 || it.address != null } if (recipients.isEmpty()) return @@ -101,9 +89,14 @@ fun PodcastValueSplits( color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f), ) - SendValueButton(value, note, episodeName, podcastName, accountViewModel) } + 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 @@ -142,65 +135,3 @@ fun PodcastValueSplits( } } } - -/** - * "Send value" button: opens a dropdown of the account's configured zap amounts. Picking one fires - * the V4V split for that many sats through [AccountViewModel.payV4V] (which fans the weighted shares - * out to each recipient). The recipient list is fixed by the show/episode, so the only choice the - * user makes is the total amount. - */ -@Composable -private fun SendValueButton( - value: PodcastValue, - note: Note, - episodeName: String?, - podcastName: String?, - accountViewModel: AccountViewModel, -) { - val context = LocalContext.current - var expanded by remember { mutableStateOf(false) } - val choices = remember { accountViewModel.zapAmountChoices() } - - Box { - FilledTonalButton( - onClick = { expanded = true }, - enabled = choices.isNotEmpty(), - ) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Text( - text = stringRes(R.string.podcast_value_send), - modifier = Modifier.padding(start = 6.dp), - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - choices.forEach { sats -> - DropdownMenuItem( - text = { Text("$sats ${stringRes(R.string.sats)}") }, - onClick = { - expanded = false - accountViewModel.toastManager.toast( - R.string.podcast_value_for_value, - R.string.podcast_value_sending, - ) - accountViewModel.payV4V( - value = value, - totalSats = sats, - podcastName = podcastName, - episodeName = episodeName, - zappedNote = note, - context = context, - ) - }, - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 5d21ad66bb..30239ac821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -161,6 +161,8 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentD 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 @@ -942,6 +944,27 @@ class AccountViewModel( onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, ) = launchSigner { + // A podcast note (episode or show) can carry a Podcasting-2.0 value-for-value block. When it + // does, "zapping" it means paying that split — lnaddress recipients go out as real zaps (with + // receipts, which drive this same button's icon/counter), node recipients go out as keysend. + // This makes the standard zap button the single payment action for V4V content. + val v4v = + (note.event as? PodcastEpisode)?.episodeValue() + ?: (note.event as? PodcastShow)?.showValue() + if (v4v != null && v4v.recipients.any { it.split > 0 && !it.address.isNullOrBlank() }) { + executeV4V( + value = v4v, + totalMilliSats = amountInMillisats, + podcastName = (note.event as? PodcastShow)?.showTitle(), + episodeName = (note.event as? PodcastEpisode)?.episodeTitle(), + zappedNote = note, + context = context, + streaming = false, + onProgress = onProgress, + ) + return@launchSigner + } + val requestedType = zapType ?: defaultZapType() // Zaps on private rumors are forced to PRIVATE so the sender and @@ -990,22 +1013,54 @@ class AccountViewModel( 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 = totalSats * 1000, + valueMsatTotal = totalMilliSats, senderName = account.userProfile().toBestDisplayName(), ) V4VPaymentHandler(account).pay( value = value, - totalMilliSats = totalSats * 1000, + 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) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cbe69fd8b5..704c10645a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1010,13 +1010,11 @@ Chapters Value-for-Value %1$d%% + Zaps to this are split between: Host Co-host Editor Verified author - Send value - Sending value… - Value sent Value-for-Value error This podcast has no payable value recipients. Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. From 496cda2f22b977ad005233c8a943a5a35b426bd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 23:05:29 +0000 Subject: [PATCH 29/39] feat(podcasts): dedicated Podcast Bookmarks screen + detail-screen toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Podcasts row to the Bookmark lists screen (mirroring Git Repositories), opening a feed of just the bookmarked podcasts: - quartz: isPodcastEvent() — a reusable predicate matching shows, episodes (NIP-F4 + Podcasting-2.0) and trailers, used to pull the podcast subset out of the mixed NIP-51 kind:10003 bookmark list. - BookmarkPodcastsFeedFilter / ...FeedViewModel / BookmarkedPodcastsScreen filter the bookmark list (public + private) down to podcast notes, newest first, and render them with the standard podcast cards. - Route.BookmarkedPodcasts + AppNavigation wiring; a "Podcasts" row with a live count in ListOfBookmarkGroupsFeedView. - The single-podcast detail screen (PodcastScreen) gains a bookmark action in its top bar that reflects and toggles bookmarked state (reusing the stateful PodcastBookmarkButton). TopBarExtensibleWithBackButton now accepts an actions slot to host it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../topbars/TopBarExtensibleWithBackButton.kt | 3 +- .../list/ListOfBookmarkGroupsFeedView.kt | 55 +++++++++++++++ .../list/ListOfBookmarkGroupsScreen.kt | 3 + .../podcasts/BookmarkedPodcastsScreen.kt | 69 +++++++++++++++++++ .../dal/BookmarkPodcastsFeedFilter.kt | 48 +++++++++++++ .../dal/BookmarkPodcastsFeedViewModel.kt | 39 +++++++++++ .../screen/loggedIn/podcasts/PodcastScreen.kt | 8 +++ amethyst/src/main/res/values/strings.xml | 2 + .../metadata/PodcastShowResolver.kt | 12 ++++ 11 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 118c70e0cb..bc929ff893 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.BookmarkedPodcastsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen @@ -454,6 +455,7 @@ fun BuildNavigation( composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } composableFromEnd { BookmarkedRepositoriesScreen(accountViewModel, nav) } + composableFromEnd { BookmarkedPodcastsScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f228606eaf..928af18b7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -315,6 +315,8 @@ sealed class Route { @Serializable object BookmarkedRepositories : Route() + @Serializable object BookmarkedPodcasts : Route() + @Serializable object BookmarkGroups : Route() @Serializable object InterestSets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt index 99c2278462..ed5f975e6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt @@ -44,13 +44,14 @@ import com.vitorpamplona.amethyst.ui.theme.isLight fun TopBarExtensibleWithBackButton( title: @Composable RowScope.() -> Unit, extendableRow: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, popBack: () -> Unit, ) { MyExtensibleTopAppBar( title = title, extendableRow = extendableRow, navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } }, - actions = {}, + actions = actions, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 37caa116b7..ea4e410601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent import kotlinx.coroutines.flow.StateFlow @Composable @@ -66,6 +67,7 @@ fun ListOfBookmarkGroupsFeedView( openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, openRepositories: () -> Unit, + openPodcasts: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -99,6 +101,11 @@ fun ListOfBookmarkGroupsFeedView( HorizontalDivider(thickness = DividerThickness) } + item { + PodcastsBookmarkList(defaultBookmarks, openPodcasts) + HorizontalDivider(thickness = DividerThickness) + } + itemsIndexed( bookmarkGroupFeedState, key = { _: Int, item: LabeledBookmarkList -> item.identifier }, @@ -249,6 +256,54 @@ fun RepositoriesBookmarkList( ) } +@Composable +fun PodcastsBookmarkList( + defaultBookmarks: BookmarkListState, + openPodcasts: () -> Unit, +) { + val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle() + + // Podcasts live in the same kind:10003 list as everything else, so count the podcast subset. + val podcastCount = + (bookmarkState.public + bookmarkState.private).count { isPodcastEvent(it.event) } + + ListItem( + modifier = Modifier.clickable(onClick = openPodcasts), + headlineContent = { + Text(stringRes(R.string.podcast_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.podcast_bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Podcasts, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = podcastCount, + articleBookmarksSize = 0, + ) + } + }, + ) +} + @Composable fun OldBookmarkList( oldBookmarks: OldBookmarkListState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index e1aa912ebd..f76f075764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -66,6 +66,7 @@ fun ListOfBookmarkGroupsScreen( openOldBookmarks = { nav.nav(Route.OldBookmarks) }, openPinnedNotes = { nav.nav(Route.PinnedNotes) }, openRepositories = { nav.nav(Route.BookmarkedRepositories) }, + openPodcasts = { nav.nav(Route.BookmarkedPodcasts) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -110,6 +111,7 @@ fun ListOfBookmarkGroupsFeed( openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, openRepositories: () -> Unit, + openPodcasts: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -159,6 +161,7 @@ fun ListOfBookmarkGroupsFeed( openOldBookmarks = openOldBookmarks, openPinnedNotes = openPinnedNotes, openRepositories = openRepositories, + openPodcasts = openPodcasts, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt new file mode 100644 index 0000000000..f4b0531ef7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal.BookmarkPodcastsFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun BookmarkedPodcastsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val podcastsFeedViewModel: BookmarkPodcastsFeedViewModel = + viewModel( + key = "NostrBookmarkPodcastsFeedViewModel", + factory = BookmarkPodcastsFeedViewModel.Factory(accountViewModel.account), + ) + + val bookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + + LaunchedEffect(bookmarks) { + podcastsFeedViewModel.invalidateData() + } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarWithBackButton(stringRes(id = R.string.podcast_bookmarks), nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableFeedView( + podcastsFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt new file mode 100644 index 0000000000..f9c83106da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent + +/** + * The podcast subset of the user's NIP-51 bookmark list (kind 10003): podcasts (shows and episodes) + * are bookmarked into the same general list as everything else, so this filter pulls just the + * podcast-typed notes ([isPodcastEvent]) back out — both public and private — newest first. + */ +class BookmarkPodcastsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List { + val bookmarks = account.bookmarkState.bookmarks.value + return (bookmarks.public + bookmarks.private) + .filter { isPodcastEvent(it.event) } + .distinct() + .sortedByDescending { it.createdAt() ?: 0L } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt new file mode 100644 index 0000000000..2c65ed9673 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class BookmarkPodcastsFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(BookmarkPodcastsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkPodcastsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index 493747c106..1556c8cd95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -53,6 +53,7 @@ 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.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 @@ -129,6 +130,13 @@ fun PodcastScreen( modifier = Modifier.weight(1f), ) }, + actions = { + // Only offer bookmarking once the show metadata has loaded — there must be a + // resolved event to add to the NIP-51 list. The button reflects bookmarked state. + if (show != null) { + PodcastBookmarkButton(metadataNote, accountViewModel) + } + }, popBack = nav::popBack, ) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 704c10645a..b8b2b2be5e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1130,6 +1130,8 @@ Public Bookmarks Repositories Your bookmarked git repositories + Podcasts + Your bookmarked podcasts and episodes Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt index 443316861d..f4a052e78b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt @@ -23,6 +23,8 @@ 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 /** @@ -34,6 +36,16 @@ 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 From 056d54434eb2b67bd67272bd70b141a08be3b00a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 13:22:31 +0000 Subject: [PATCH 30/39] fix(podcasts): bookmark button now reflects state + confirms with a toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PodcastBookmarkButton read `note in bookmarks.public` — an identity-based List containment that is unreliable for addressable shows/episodes (the rendered note instance may differ from the one rebuilt from the a-tag), so the icon never flipped and there was no way to tell a podcast was already bookmarked. Switch to the public bookmark id/address sets (the same reactive pattern the working git-repository bookmark button uses): match note.address for addressable notes and note.idHex otherwise. The icon now reliably shows bookmarked vs not (filled/primary vs outline/onSurfaceVariant), and each tap fires a confirming toast so the action is never silent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../ui/note/types/PodcastBookmarkButton.kt | 28 +++++++++++++++---- amethyst/src/main/res/values/strings.xml | 2 ++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt index d6957ed6d5..7bda05e32f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt @@ -24,11 +24,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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 @@ -39,6 +41,10 @@ import com.vitorpamplona.amethyst.ui.stringRes * recommendation the dedicated favorites list (10054) was meant for, and the note then appears in * the standard Bookmarks screen. Works for both regular events (e-tag) and addressable shows/ * episodes (a-tag) because [AccountViewModel.addPublicBookmark] branches on the note type. + * + * Bookmarked state is read from the public bookmark id/address **sets** (not `List` + * containment) so it reflects reliably for addressable notes and updates the moment the list + * changes; a toast confirms each add/remove so the action is never silent. */ @Composable fun PodcastBookmarkButton( @@ -46,27 +52,39 @@ fun PodcastBookmarkButton( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { - val bookmarks by accountViewModel.account.bookmarkState.bookmarks - .collectAsStateWithLifecycle() - val isBookmarked = note in bookmarks.public + 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 + } + } IconButton( onClick = { if (isBookmarked) { accountViewModel.removePublicBookmark(note) + accountViewModel.toastManager.toast(R.string.bookmarks_title, R.string.podcast_bookmark_removed) } else { accountViewModel.addPublicBookmark(note) + accountViewModel.toastManager.toast(R.string.bookmarks_title, R.string.podcast_bookmark_added) } }, modifier = modifier, ) { Icon( - symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkBorder, + 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 = MaterialTheme.colorScheme.primary, + tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b8b2b2be5e..55cbee93e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1011,6 +1011,8 @@ Value-for-Value %1$d%% Zaps to this are split between: + Added to your bookmarks + Removed from your bookmarks Host Co-host Editor From a61ec2911c917ea9d8d766e48d5dd3ca8e089de0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 14:27:40 +0000 Subject: [PATCH 31/39] fix(podcasts): compact bookmark button, drop the confirmation toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The bookmark toggle was a Material IconButton (48dp minimum touch target), so it stood taller than the titleLarge line it sits beside and broke the side-by-side alignment. Render it as a compact clickable glyph sized to a single title line (iconSize, default 20dp) so title and button align. - Drop the add/remove toast — the icon flipping filled/outline is enough feedback — and remove the now-unused strings. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../ui/note/types/PodcastBookmarkButton.kt | 45 +++++++++++++------ amethyst/src/main/res/values/strings.xml | 2 - 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt index 7bda05e32f..6e33d108df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt @@ -20,12 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.material3.IconButton +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 @@ -44,13 +53,18 @@ import com.vitorpamplona.amethyst.ui.stringRes * * Bookmarked state is read from the public bookmark id/address **sets** (not `List` * containment) so it reflects reliably for addressable notes and updates the moment the list - * changes; a toast confirms each add/remove so the action is never silent. + * 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 @@ -66,17 +80,21 @@ fun PodcastBookmarkButton( } } - IconButton( - onClick = { - if (isBookmarked) { - accountViewModel.removePublicBookmark(note) - accountViewModel.toastManager.toast(R.string.bookmarks_title, R.string.podcast_bookmark_removed) - } else { - accountViewModel.addPublicBookmark(note) - accountViewModel.toastManager.toast(R.string.bookmarks_title, R.string.podcast_bookmark_added) - } - }, - modifier = modifier, + 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, @@ -85,6 +103,7 @@ fun PodcastBookmarkButton( if (isBookmarked) R.string.remove_from_public_bookmarks else R.string.add_to_public_bookmarks, ), tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(iconSize), ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 55cbee93e7..b8b2b2be5e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1011,8 +1011,6 @@ Value-for-Value %1$d%% Zaps to this are split between: - Added to your bookmarks - Removed from your bookmarks Host Co-host Editor From 69f47f2351826470f8a8bbea31f9e4f5a8c310be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 14:57:25 +0000 Subject: [PATCH 32/39] feat(podcasts): add NoteCompose 3-dot options menu to the detail top bar The single-podcast screen's top bar now shows the standard MoreOptionsButton (the NoteCompose 3-dot menu) to the right of the bookmark button, giving the show/episode the usual note actions (share, report, mute, etc.). Both appear only once the show metadata has resolved. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../ui/screen/loggedIn/podcasts/PodcastScreen.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index 1556c8cd95..a7034479e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -53,6 +55,7 @@ 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 @@ -61,6 +64,7 @@ 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 @@ -131,10 +135,14 @@ fun PodcastScreen( ) }, actions = { - // Only offer bookmarking once the show metadata has loaded — there must be a - // resolved event to add to the NIP-51 list. The button reflects bookmarked state. + // 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, From 9d4cb99398f66ded53c19eac38671ec7d6362251 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 15:19:39 +0000 Subject: [PATCH 33/39] feat(podcasts): surface NIP-22 comments on episode list rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Episode comments already work end to end — replying to an episode routes to the NIP-22 GenericCommentPost composer (kind 1111 scoped to the episode address), the thread datasource fetches them via #a/#A, and the thread view renders them with a full reactions row. The only gap was discoverability on the podcast-specific surfaces. Add a comments chip (comment glyph + live reply count via observeNoteReplyCount) to PodcastEpisodeListItem. It reads "N comments" (or "Comment" when empty) and opens the episode's thread, where the discussion lives and new comments are composed. Reuses the existing thread/composer machinery — no new event plumbing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../podcasts/PodcastEpisodeListItem.kt | 46 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 5 ++ 2 files changed, 51 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index d088629e95..a55c2af060 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -23,27 +23,34 @@ 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.shape.RoundedCornerShape 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.draw.clip import androidx.compose.ui.platform.LocalContext +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.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.CommentIcon 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.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.podcasts.PodcastEpisode @@ -132,5 +139,44 @@ fun PodcastEpisodeListItem( accountViewModel = accountViewModel, ) } + + EpisodeCommentsChip(note, accountViewModel, nav) + } +} + +/** + * A "comments" affordance for an episode list row: the NIP-22 (kind 1111) reply count plus a comment + * glyph, opening the episode's thread where the discussion lives and new comments are composed. + * Everything downstream — fetching `#a`/`#A` comments, the reply composer, the count — already works + * through the standard thread; this just surfaces it on the podcast-specific list. + */ +@Composable +private fun EpisodeCommentsChip( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val commentCount by observeNoteReplyCount(note, accountViewModel) + + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CommentIcon(Size18Modifier, MaterialTheme.colorScheme.grayText) + Text( + text = + if (commentCount == 0) { + stringRes(R.string.podcast_comment_action) + } else { + pluralStringResource(R.plurals.podcast_comment_count, commentCount, commentCount) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b8b2b2be5e..dfd388bf96 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1011,6 +1011,11 @@ Value-for-Value %1$d%% Zaps to this are split between: + Comment + + %1$d comment + %1$d comments + Host Co-host Editor From ec1ea54dcca4b6b180ff71f1310c0326c80eeb76 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 15:46:56 +0000 Subject: [PATCH 34/39] feat(podcasts): standard ReactionsRow for the show between header and episodes Give the podcast show itself the usual engagement affordance: the standard NoteCompose ReactionsRow (comment / zap / react, with counts) now sits between the show header and the episode list on the detail screen, bracketed by the usual dividers like any other content detail. Acts on the resolved show note and only renders once that event loads. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../screen/loggedIn/podcasts/PodcastHeader.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 31970e4661..4d880a85ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -44,6 +44,7 @@ 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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -137,9 +138,26 @@ fun PodcastHeader( } } + // 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, + ) + } + // 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, From 429ec9177e49f5190da135dea4a7674ed072e4d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 16:31:33 +0000 Subject: [PATCH 35/39] feat(podcasts): Podcasting-2.0 person credits and soundbites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two Podcasting-2.0 features to the podcast stack: Persons (podcast:person) — hosts/guests as free-text credits with role, avatar, and link (not necessarily Nostr users): - quartz: PodcastPerson model; PersonTag ["person", name, role, img, href] on kind 30054 episodes; a persons[] array in the kind 30078 show JSON. Exposed via PodcastEpisode.episodePersons() / PodcastShow.showPersons(). - UI: PodcastPeople — a "Hosts & Guests" avatar strip (robohash fallback, tap opens href), shown on the episode card and the show header. Soundbites (podcast:soundbite) — highlight clips: - quartz: PodcastSoundbite model; SoundbiteTag ["soundbite", start, dur, title?] on episodes; PodcastEpisode.episodeSoundbites(). - UI: PodcastSoundbites — "jump to the good part" chips under the audio player that seek the live media controller to the clip's start. Both parse leniently, round-trip through build(), and are covered by PodcastPersonSoundbiteTest. NIP-F4 returns empty for both (no such tags), so nothing renders there. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/types/PodcastEpisode.kt | 5 + .../note/types/PodcastEpisodeAudioPlayer.kt | 9 ++ .../amethyst/ui/note/types/PodcastPeople.kt | 137 ++++++++++++++++++ .../ui/note/types/PodcastSoundbites.kt | 90 ++++++++++++ .../screen/loggedIn/podcasts/PodcastHeader.kt | 4 + amethyst/src/main/res/values/strings.xml | 2 + .../episode/Podcasting20EpisodeEvent.kt | 16 ++ .../episode/TagArrayBuilderExt.kt | 8 + .../episode/tags/PersonTag.kt | 59 ++++++++ .../episode/tags/SoundbiteTag.kt | 53 +++++++ .../metadata/Podcasting20PodcastMetadata.kt | 4 + .../quartz/podcasts/PodcastEpisode.kt | 12 ++ .../quartz/podcasts/PodcastPerson.kt | 49 +++++++ .../quartz/podcasts/PodcastShow.kt | 6 + .../quartz/podcasts/PodcastSoundbite.kt | 41 ++++++ .../PodcastPersonSoundbiteTest.kt | 131 +++++++++++++++++ 16 files changed, 626 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index a8a7f33572..d334023df1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -198,6 +198,11 @@ fun RenderPodcastEpisode( PodcastValueSplits(value = it) } + if (!makeItShort) { + val persons = remember(noteEvent) { episode.episodePersons() } + PodcastPeople(persons, accountViewModel) + } + markdown?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 8b08ee63bd..4821a71ccc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -76,6 +76,10 @@ fun PodcastEpisodeAudioPlayer( // 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() } + Column(Modifier.fillMaxWidth()) { GetMediaItem( videoUri = audio.url, @@ -117,6 +121,11 @@ fun PodcastEpisodeAudioPlayer( accountViewModel = accountViewModel, ) } + + PodcastSoundbites(soundbites) { startMillis -> + controller.controller.seekTo(startMillis) + controller.controller.play() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt new file mode 100644 index 0000000000..a08d9f0925 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.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.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.ui.components.RobohashFallbackAsyncImage +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.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 free-text (not a + * Nostr user), so we load their [PodcastPerson.img] with a robohash fallback seeded by their name, + * and tapping one opens their [PodcastPerson.href] link when present. + */ +@Composable +fun PodcastPeople( + persons: List, + accountViewModel: AccountViewModel, +) { + 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) + } + } + } +} + +@Composable +private fun PersonItem( + person: PodcastPerson, + accountViewModel: AccountViewModel, +) { + val uriHandler = LocalUriHandler.current + val href = person.href + + Column( + modifier = + Modifier + .width(72.dp) + .then( + if (href != null) { + Modifier.clickable { runCatching { uriHandler.openUri(href) } } + } else { + Modifier + }, + ).padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + 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(), + ) + + Text( + text = person.name, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + person.role?.takeIf { it.isNotEmpty() }?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt new file mode 100644 index 0000000000..00825d41a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite + +/** + * The episode's Podcasting-2.0 `podcast:soundbite` highlight clips as "jump to the good part" chips. + * Tapping one calls [onPlayFrom] with the clip's start offset in milliseconds; the host wires that + * to the media controller so playback seeks there. Kept controller-agnostic so it can live wherever + * a seek callback is available. + */ +@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) +@Composable +fun PodcastSoundbites( + soundbites: List, + onPlayFrom: (startMillis: Long) -> Unit, +) { + if (soundbites.isEmpty()) return + + FlowRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + soundbites.forEach { soundbite -> + val label = soundbite.title?.takeIf { it.isNotBlank() } ?: formatClock(soundbite.startTimeSeconds) + AssistChip( + onClick = { onPlayFrom(soundbite.startMillis()) }, + label = { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + ) + }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.PlayArrow, + contentDescription = stringRes(R.string.podcast_play_soundbite), + modifier = Modifier.size(AssistChipDefaults.IconSize), + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + } + } +} + +private fun formatClock(totalSeconds: Double): String { + val total = totalSeconds.toLong() + val minutes = total / 60 + val seconds = total % 60 + val secStr = if (seconds < 10) "0$seconds" else "$seconds" + return "$minutes:$secStr" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 4d880a85ea..057706340c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -46,6 +46,7 @@ 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 @@ -136,6 +137,9 @@ fun PodcastHeader( if (claimedAuthors.isNotEmpty() && podcastPubkey != null) { PodcastAuthors(podcastPubkey, claimedAuthors, accountViewModel, nav) } + + val persons = remember(show) { show?.showPersons() ?: emptyList() } + PodcastPeople(persons, accountViewModel) } // Standard engagement row for the show itself (comment / zap / react), like any other diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index dfd388bf96..0d60765aa6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1012,6 +1012,8 @@ %1$d%% Zaps to this are split between: Comment + Hosts & Guests + Play highlight %1$d comment %1$d comments diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt index 670214c782..96be4d42e0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -38,14 +38,18 @@ 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 @@ -92,6 +96,10 @@ class Podcasting20EpisodeEvent( 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. */ @@ -128,6 +136,10 @@ class Podcasting20EpisodeEvent( override fun episodeValue() = value() + override fun episodePersons() = persons() + + override fun episodeSoundbites() = soundbites() + companion object { const val KIND = 30054 @@ -146,6 +158,8 @@ class Podcasting20EpisodeEvent( transcriptUrl: String? = null, chaptersUrl: String? = null, value: PodcastValue? = null, + persons: List = emptyList(), + soundbites: List = emptyList(), topics: List = emptyList(), markdownContent: String = "", createdAt: Long = TimeUtils.now(), @@ -166,6 +180,8 @@ class Podcasting20EpisodeEvent( transcriptUrl?.let { transcript(it) } chaptersUrl?.let { chapters(it) } value?.let { value(it) } + persons.filter { it.isValid() }.forEach { person(it) } + soundbites.forEach { soundbite(it) } if (topics.isNotEmpty()) hashtags(topics) initializer() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt index 3607a898f4..bfe6c921bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt @@ -29,13 +29,17 @@ import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EpisodeNumberTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ValueTag import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite import com.vitorpamplona.quartz.podcasts.PodcastValue fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) @@ -58,6 +62,10 @@ fun TagArrayBuilder.chapters(url: String) = addUnique( fun TagArrayBuilder.value(value: PodcastValue) = addUnique(ValueTag.assemble(value)) +fun TagArrayBuilder.person(person: PodcastPerson) = add(PersonTag.assemble(person)) + +fun TagArrayBuilder.soundbite(soundbite: PodcastSoundbite) = add(SoundbiteTag.assemble(soundbite)) + fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) fun TagArrayBuilder.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt new file mode 100644 index 0000000000..4186262de8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode person credit: `["person", "", "", "", ""]`. + * + * Only the name (slot 1) is required; role/img/href are optional and carried positionally, with + * empty strings standing in for absent middle values. Maps to the shared [PodcastPerson] holder. + * (The show-level `podcast:person` `group` attribute isn't carried on the episode tag — it's + * organizational metadata that lives in the show's JSON when present.) + */ +class PersonTag { + companion object { + const val TAG_NAME = "person" + + fun parse(tag: Array): PodcastPerson? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return PodcastPerson( + name = tag[1], + role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }, + img = tag.getOrNull(3)?.takeIf { it.isNotEmpty() }, + href = tag.getOrNull(4)?.takeIf { it.isNotEmpty() }, + ) + } + + fun assemble(person: PodcastPerson): Array { + // Trim trailing empties so a person with only a name is a 2-element tag, but keep empty + // placeholders in the middle so href stays in slot 4 when role/img are missing. + val slots = listOf(person.name, person.role ?: "", person.img ?: "", person.href ?: "") + val lastNonEmpty = slots.indexOfLast { it.isNotEmpty() }.coerceAtLeast(0) + return (listOf(TAG_NAME) + slots.subList(0, lastNonEmpty + 1)).toTypedArray() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt new file mode 100644 index 0000000000..cc104dca99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode soundbite: `["soundbite", "", "", ""]`. + * + * `startTime` and `duration` are in seconds (may be fractional). Title is optional. Maps to the + * shared [PodcastSoundbite] holder. A soundbite with a non-positive duration is dropped as invalid. + */ +class SoundbiteTag { + companion object { + const val TAG_NAME = "soundbite" + + fun parse(tag: Array): PodcastSoundbite? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val start = tag[1].toDoubleOrNull() ?: return null + val duration = tag[2].toDoubleOrNull() ?: return null + ensure(start >= 0.0) { return null } + ensure(duration > 0.0) { return null } + return PodcastSoundbite(start, duration, tag.getOrNull(3)?.takeIf { it.isNotEmpty() }) + } + + fun assemble(soundbite: PodcastSoundbite): Array { + val head = arrayOf(TAG_NAME, soundbite.startTimeSeconds.toString(), soundbite.durationSeconds.toString()) + val title = soundbite.title + return if (title.isNullOrEmpty()) head else head + title + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt index 61a8030ad9..3129ed1edd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -24,6 +24,7 @@ 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 @@ -66,6 +67,8 @@ class Podcasting20PodcastMetadata( override fun showValue() = content.value + override fun showPersons() = content.persons.filter { it.isValid() } + fun language() = content.language /** Contact email for the show, if provided. */ @@ -103,6 +106,7 @@ class Podcasting20PodcastMetadata( val complete: Boolean? = null, val guid: String? = null, val value: PodcastValue? = null, + val persons: List = emptyList(), ) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt index 46a87e606e..c0fd1cc3d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt @@ -84,4 +84,16 @@ interface PodcastEpisode { * V4V and returns null. */ fun episodeValue(): PodcastValue? = null + + /** + * Hosts/guests credited on this specific episode (Podcasting-2.0 `podcast:person`). Empty when + * the publisher lists no per-episode people (NIP-F4 has no person tag and returns empty). + */ + fun episodePersons(): List = emptyList() + + /** + * Highlight clips of the episode (Podcasting-2.0 `podcast:soundbite`), each a start offset + + * duration into the audio. Empty when none are declared. + */ + fun episodeSoundbites(): List = emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt new file mode 100644 index 0000000000..f6914f9bd3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +/** + * A Podcasting-2.0 `podcast:person` — a host, guest, or other contributor credited on a show or a + * single episode. Unlike a NIP-F4 author (a Nostr pubkey), a person is free-text: a [name] plus an + * optional [role]/[group], an avatar [img] URL, and a [href] link to their page. None of these need + * to be a Nostr identity, so it maps RSS `` credits straight through. + * + * Carried two ways: show-level persons live in the `kind:30078` metadata JSON (a `persons` array), + * and episode-level persons are `["person", ...]` tags on the `kind:30054` event. + */ +@Immutable +@Serializable +class PodcastPerson( + val name: String = "", + /** e.g. "host", "guest", "cohost" — free text per the Podcasting 2.0 taxonomy. */ + val role: String? = null, + /** Grouping such as "cast" or "writing"; rarely displayed, kept for round-trip fidelity. */ + val group: String? = null, + /** Avatar image URL. */ + val img: String? = null, + /** Link to the person's page/profile. */ + val href: String? = null, +) { + fun isValid() = name.isNotBlank() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt index 9aca7484bd..dcf87a2f8a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt @@ -71,4 +71,10 @@ interface PodcastShow { /** The show's default value-for-value split block, if any. NIP-F4 has no V4V and returns null. */ fun showValue(): PodcastValue? = null + + /** + * Show-level hosts/guests (Podcasting-2.0 `podcast:person`) — the recurring cast credited across + * the podcast. Empty when none are declared (NIP-F4 uses author p-tags instead and returns empty). + */ + fun showPersons(): List = emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt new file mode 100644 index 0000000000..e88cb2fbc8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import androidx.compose.runtime.Immutable + +/** + * A Podcasting-2.0 `podcast:soundbite` — a highlight clip of an episode, defined as a + * [startTimeSeconds] offset into the episode audio and a [durationSeconds] length, with an optional + * [title]. Players surface these as tappable "jump to the good part" chips. + * + * Carried as `["soundbite", "", "", ""]` tags on the episode + * event; times are in seconds and may be fractional. + */ +@Immutable +class PodcastSoundbite( + val startTimeSeconds: Double, + val durationSeconds: Double, + val title: String? = null, +) { + /** Start offset in whole milliseconds, ready for a media player `seekTo`. */ + fun startMillis(): Long = (startTimeSeconds * 1000).toLong() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt new file mode 100644 index 0000000000..536038f965 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastPersonSoundbiteTest { + @Test + fun `person tag parses all fields`() { + val person = PersonTag.parse(arrayOf("person", "Alice", "host", "https://img/a.png", "https://alice.example")) + assertEquals("Alice", person?.name) + assertEquals("host", person?.role) + assertEquals("https://img/a.png", person?.img) + assertEquals("https://alice.example", person?.href) + } + + @Test + fun `person tag with only a name`() { + val person = PersonTag.parse(arrayOf("person", "Bob")) + assertEquals("Bob", person?.name) + assertNull(person?.role) + assertNull(person?.img) + assertNull(person?.href) + } + + @Test + fun `person tag keeps href in slot 4 when role and img are blank`() { + val person = PersonTag.parse(arrayOf("person", "Carol", "", "", "https://carol.example")) + assertEquals("Carol", person?.name) + assertNull(person?.role) + assertNull(person?.img) + assertEquals("https://carol.example", person?.href) + } + + @Test + fun `person tag round-trips through assemble`() { + val original = PodcastPerson(name = "Dave", role = "guest", img = "https://img/d.png") + val reparsed = PersonTag.parse(PersonTag.assemble(original)) + assertEquals("Dave", reparsed?.name) + assertEquals("guest", reparsed?.role) + assertEquals("https://img/d.png", reparsed?.img) + assertNull(reparsed?.href) + } + + @Test + fun `nameless person tag is dropped`() { + assertNull(PersonTag.parse(arrayOf("person"))) + assertNull(PersonTag.parse(arrayOf("person", ""))) + } + + @Test + fun `soundbite tag parses times and optional title`() { + val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "73.5", "60.0", "Best moment")) + assertEquals(73.5, soundbite?.startTimeSeconds) + assertEquals(60.0, soundbite?.durationSeconds) + assertEquals("Best moment", soundbite?.title) + assertEquals(73500L, soundbite?.startMillis()) + } + + @Test + fun `soundbite tag without title`() { + val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "0", "30")) + assertEquals(0.0, soundbite?.startTimeSeconds) + assertEquals(30.0, soundbite?.durationSeconds) + assertNull(soundbite?.title) + } + + @Test + fun `soundbite with bad or non-positive numbers is dropped`() { + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "abc", "60"))) + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "10", "0"))) + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "10"))) + } + + @Test + fun `soundbite round-trips through assemble`() { + val reparsed = SoundbiteTag.parse(SoundbiteTag.assemble(PodcastSoundbite(12.0, 45.0, "Clip"))) + assertEquals(12.0, reparsed?.startTimeSeconds) + assertEquals(45.0, reparsed?.durationSeconds) + assertEquals("Clip", reparsed?.title) + } + + @Test + fun `show metadata JSON parses a persons array`() { + val json = + """{"title":"My Show","persons":[{"name":"Alice","role":"host","img":"https://img/a.png"},{"name":"Bob","role":"guest"}]}""" + val event = AppSpecificDataEvent("id", "pk", 0, arrayOf(arrayOf("d", "podcast-metadata")), json, "sig") + val show = Podcasting20PodcastMetadata.parse(event) + val persons = show?.showPersons().orEmpty() + assertEquals(2, persons.size) + assertEquals("Alice", persons[0].name) + assertEquals("host", persons[0].role) + assertEquals("https://img/a.png", persons[0].img) + assertEquals("Bob", persons[1].name) + assertEquals("guest", persons[1].role) + } + + @Test + fun `show metadata without persons is empty, not a crash`() { + val event = AppSpecificDataEvent("id", "pk", 0, arrayOf(arrayOf("d", "podcast-metadata")), """{"title":"Bare"}""", "sig") + val show = Podcasting20PodcastMetadata.parse(event) + assertTrue(show?.showPersons().orEmpty().isEmpty()) + } +} From a98ec62004026efa722c7aa48beabb444499e9ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 18:15:24 +0000 Subject: [PATCH 36/39] feat(podcasts): render nostr-native podcast:person credits as real profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Podcasting-2.0 person's href points at an npub/nprofile (bare, nostr: URI, or an njump-style link), upgrade the free-text credit to a real Nostr profile: the standard ClickableUserPicture + UsernameDisplay, tappable through to the profile. Plain web links keep the free-text card with the default profile-image loader. - quartz: PodcastPerson.nostrPubKey() resolves href → pubkey via Nip19Parser (npub/nprofile only). Covered by PodcastPersonSoundbiteTest. - UI: PodcastPeople branches per person — LoadUser + standard profile components for nostr identities, free-text card otherwise — sharing one card scaffold so both look identical in the Hosts & Guests strip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/types/PodcastEpisode.kt | 2 +- .../amethyst/ui/note/types/PodcastPeople.kt | 129 ++++++++++++++---- .../screen/loggedIn/podcasts/PodcastHeader.kt | 2 +- .../quartz/podcasts/PodcastPerson.kt | 18 +++ .../PodcastPersonSoundbiteTest.kt | 14 ++ 5 files changed, 133 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index d334023df1..2a42dff222 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -200,7 +200,7 @@ fun RenderPodcastEpisode( if (!makeItShort) { val persons = remember(noteEvent) { episode.episodePersons() } - PodcastPeople(persons, accountViewModel) + PodcastPeople(persons, accountViewModel, nav) } markdown?.takeIf { !makeItShort }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt index a08d9f0925..8d4552acfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt @@ -33,6 +33,7 @@ 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 @@ -41,8 +42,14 @@ 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 @@ -50,14 +57,18 @@ 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 free-text (not a - * Nostr user), so we load their [PodcastPerson.img] with a robohash fallback seeded by their name, - * and tapping one opens their [PodcastPerson.href] link when present. + * rendered as a horizontally scrollable row of avatar + name + role. + * + * A person is usually a free-text credit (name + image URL + web link), not a Nostr user, so it's + * drawn with the app's default profile-image loader and its link opens externally. But when the + * publisher's `href` points at an `npub`/`nprofile`, we upgrade the card to a real Nostr profile — + * the standard [ClickableUserPicture] + [UsernameDisplay], tappable through to the profile. */ @Composable fun PodcastPeople( persons: List, accountViewModel: AccountViewModel, + nav: INav, ) { val people = persons.filter { it.isValid() } if (people.isEmpty()) return @@ -76,7 +87,7 @@ fun PodcastPeople( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { items(people) { person -> - PersonItem(person, accountViewModel) + PersonItem(person, accountViewModel, nav) } } } @@ -86,43 +97,101 @@ fun PodcastPeople( 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 (href != null) { - Modifier.clickable { runCatching { uriHandler.openUri(href) } } - } else { - Modifier - }, - ).padding(vertical = 4.dp), + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - 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(), - ) - - Text( - text = person.name, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - - person.role?.takeIf { it.isNotEmpty() }?.let { + avatar() + name() + role?.takeIf { it.isNotEmpty() }?.let { Text( text = it, style = MaterialTheme.typography.labelSmall, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 057706340c..50c2232889 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -139,7 +139,7 @@ fun PodcastHeader( } val persons = remember(show) { show?.showPersons() ?: emptyList() } - PodcastPeople(persons, accountViewModel) + PodcastPeople(persons, accountViewModel, nav) } // Standard engagement row for the show itself (comment / zap / react), like any other diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt index f6914f9bd3..2ed1ca1e55 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt @@ -21,6 +21,10 @@ 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 /** @@ -46,4 +50,18 @@ class PodcastPerson( val href: String? = null, ) { fun isValid() = name.isNotBlank() + + /** + * The Nostr pubkey (hex) this person points at, when [href] is (or embeds) an `npub`/`nprofile` + * — including `nostr:` URIs and `njump.me`-style links. Null for a plain web link or no href. + * Lets a client upgrade a free-text credit to a real Nostr profile when the publisher linked one. + */ + fun nostrPubKey(): HexKey? = + href?.let { + when (val entity = Nip19Parser.uriToRoute(it)?.entity) { + is NPub -> entity.hex + is NProfile -> entity.hex + else -> null + } + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt index 536038f965..0f990e6aad 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt @@ -75,6 +75,20 @@ class PodcastPersonSoundbiteTest { assertNull(PersonTag.parse(arrayOf("person", ""))) } + @Test + fun `person href resolves an npub to a pubkey`() { + val npub = "npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y" + val hex = "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365" + assertEquals(hex, PodcastPerson(name = "Alice", href = npub).nostrPubKey()) + assertEquals(hex, PodcastPerson(name = "Alice", href = "nostr:$npub").nostrPubKey()) + } + + @Test + fun `person href that is a plain web link has no pubkey`() { + assertNull(PodcastPerson(name = "Alice", href = "https://alice.example").nostrPubKey()) + assertNull(PodcastPerson(name = "Alice", href = null).nostrPubKey()) + } + @Test fun `soundbite tag parses times and optional title`() { val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "73.5", "60.0", "Best moment")) From 9ac1ba692f3b5c07ad20db18720a7e5003a487a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 19:22:19 +0000 Subject: [PATCH 37/39] feat(podcasts): Top Supporters leaderboard, in-app chapters, transcript viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings three of PodStr's engagement/reading widgets to Amethyst's podcast screens, reusing existing infrastructure: - Top Supporters — a sats-ranked zap leaderboard on the show header, top 3 flagged with gold/silver/bronze medals, tap-through to profiles. Aggregates the show note's zaps through the same LiveActivityTopZappersAggregator the live-stream leaderboard uses. Anonymous zaps collapse into one bucket. - In-app Chapters — fetches the Podcasting-2.0 chapters.json referenced by the episode's `chapters` tag and renders a collapsible, tappable list; tapping a chapter seeks the live media controller (same seek path as soundbites). - Transcript viewer — fetches the `transcript` file and shows it in a collapsible scrollable panel, stripping VTT/SRT scaffolding into flowing text. Adds PodcastRemoteContent (a bounded URL text fetcher) for the two off-event side files. Also removes the now-redundant per-episode comment chip from the episode list rows — the show ReactionsRow and the episode thread already cover commenting. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../service/podcasts/PodcastRemoteContent.kt | 64 ++++++ .../ui/note/types/PodcastChaptersView.kt | 167 ++++++++++++++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 3 + .../note/types/PodcastEpisodeAudioPlayer.kt | 14 ++ .../ui/note/types/PodcastTranscriptView.kt | 111 +++++++++++ .../podcasts/PodcastEpisodeListItem.kt | 46 ----- .../screen/loggedIn/podcasts/PodcastHeader.kt | 2 + .../loggedIn/podcasts/PodcastTopSupporters.kt | 188 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 8 +- 9 files changed, 553 insertions(+), 50 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt new file mode 100644 index 0000000000..5fafb991be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.podcasts + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync + +/** + * Fetches the off-event side files a podcast episode references by URL — the Podcasting-2.0 + * `chapters.json` document and the `transcript` file — so the client can render them in-app. + * A bounded read cap keeps a hostile/huge file from blowing up memory. + */ +object PodcastRemoteContent { + /** Refuse bodies larger than this (chapters/transcripts are small text files). */ + private const val MAX_BYTES = 2_000_000L + + suspend fun fetchText( + url: String, + okHttpClient: OkHttpClient, + ): String? = + withContext(Dispatchers.IO) { + try { + val request = + Request + .Builder() + .url(url) + .get() + .build() + okHttpClient.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) return@use null + val body = response.body ?: return@use null + // Reject an oversized declared length outright; cap the read for chunked bodies. + if (body.contentLength() > MAX_BYTES) return@use null + body.string().take(MAX_BYTES.toInt()) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt new file mode 100644 index 0000000000..a5706f8891 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.podcasts.PodcastChapter +import com.vitorpamplona.quartz.podcasts.PodcastChapters + +/** + * The episode's Podcasting-2.0 chapters (from the off-event `chapters.json` referenced by the + * `chapters` tag), fetched and rendered as a collapsible, tappable list. Tapping a chapter calls + * [onSeek] with its start in milliseconds so the host can seek the live media controller — the same + * contract as [PodcastSoundbites]. + */ +@Composable +fun PodcastChaptersView( + chaptersUrl: String, + onSeek: (startMillis: Long) -> Unit, + accountViewModel: AccountViewModel, +) { + val chapters by produceState(initialValue = emptyList(), chaptersUrl) { + val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl) + val body = PodcastRemoteContent.fetchText(chaptersUrl, client) + value = body?.let { PodcastChapters.parse(it)?.chapters }?.filter { it.title?.isNotBlank() == true } ?: emptyList() + } + + if (chapters.isEmpty()) return + + var expanded by remember(chaptersUrl) { mutableStateOf(false) } + + Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + CollapsibleHeader( + symbol = MaterialSymbols.AutoMirrored.FormatListBulleted, + title = pluralStringResource(R.plurals.podcast_chapters_count, chapters.size, chapters.size), + expanded = expanded, + onToggle = { expanded = !expanded }, + ) + + if (expanded) { + chapters.forEach { chapter -> + ChapterRow(chapter, onSeek) + } + } + } +} + +@Composable +private fun ChapterRow( + chapter: PodcastChapter, + onSeek: (Long) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onSeek(chapter.startSeconds() * 1000) } + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = formatChapterTime(chapter.startTime), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + Text( + text = chapter.title.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +internal fun CollapsibleHeader( + symbol: MaterialSymbol, + title: String, + expanded: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Size18Modifier, + tint = MaterialTheme.colorScheme.grayText, + ) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.grayText, + ) + } +} + +private fun formatChapterTime(totalSeconds: Double): String { + val total = totalSeconds.toLong() + val h = total / 3600 + val m = (total % 3600) / 60 + val s = total % 60 + val two = { n: Long -> if (n < 10) "0$n" else "$n" } + return if (h > 0) "$h:${two(m)}:${two(s)}" else "$m:${two(s)}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 2a42dff222..fc9e6e3930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -201,6 +201,9 @@ fun RenderPodcastEpisode( 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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 4821a71ccc..b3b7093468 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -80,6 +80,9 @@ fun PodcastEpisodeAudioPlayer( // 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, @@ -126,6 +129,17 @@ fun PodcastEpisodeAudioPlayer( controller.controller.seekTo(startMillis) controller.controller.play() } + + chaptersUrl?.let { url -> + PodcastChaptersView( + chaptersUrl = url, + onSeek = { startMillis -> + controller.controller.seekTo(startMillis) + controller.controller.play() + }, + accountViewModel = accountViewModel, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt new file mode 100644 index 0000000000..86e24c54f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * The episode's transcript (from the off-event file referenced by the `transcript` tag), fetched and + * shown in a collapsible, scrollable panel. VTT/SRT scaffolding (the `WEBVTT` header, cue indices, + * and `-->` timing lines) is stripped so it reads as flowing text; plain-text transcripts pass + * through unchanged. + */ +@Composable +fun PodcastTranscriptView( + transcriptUrl: String, + accountViewModel: AccountViewModel, +) { + val transcript by produceState(initialValue = null as String?, transcriptUrl) { + val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(transcriptUrl) + val body = PodcastRemoteContent.fetchText(transcriptUrl, client) + value = body?.let { cleanTranscript(it) }?.takeIf { it.isNotBlank() } + } + + val text = transcript ?: return + + var expanded by remember(transcriptUrl) { mutableStateOf(false) } + + Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + CollapsibleHeader( + symbol = MaterialSymbols.Description, + title = stringRes(R.string.podcast_transcript), + expanded = expanded, + onToggle = { expanded = !expanded }, + ) + + if (expanded) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .verticalScroll(rememberScrollState()) + .padding(12.dp), + ) + } + } +} + +/** + * Strips VTT/SRT caption scaffolding and joins the remaining caption lines into readable prose. + * Leaves plain-text transcripts effectively untouched. Falls back to the raw body if the cleanup + * removed everything (e.g. an unexpected format). + */ +private fun cleanTranscript(raw: String): String { + val out = StringBuilder() + for (line in raw.lineSequence()) { + val t = line.trim() + if (t.isEmpty()) continue + if (t == "WEBVTT") continue + if (t.startsWith("NOTE ")) continue + if (t.contains("-->")) continue // cue timing line + if (t.toIntOrNull() != null) continue // SRT cue index + out.append(t).append(' ') + } + return out.toString().trim().ifEmpty { raw.trim() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index a55c2af060..d088629e95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -23,34 +23,27 @@ 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.shape.RoundedCornerShape 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.draw.clip import androidx.compose.ui.platform.LocalContext -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.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.note.CommentIcon 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.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.podcasts.PodcastEpisode @@ -139,44 +132,5 @@ fun PodcastEpisodeListItem( accountViewModel = accountViewModel, ) } - - EpisodeCommentsChip(note, accountViewModel, nav) - } -} - -/** - * A "comments" affordance for an episode list row: the NIP-22 (kind 1111) reply count plus a comment - * glyph, opening the episode's thread where the discussion lives and new comments are composed. - * Everything downstream — fetching `#a`/`#A` comments, the reply composer, the count — already works - * through the standard thread; this just surfaces it on the podcast-specific list. - */ -@Composable -private fun EpisodeCommentsChip( - note: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val commentCount by observeNoteReplyCount(note, accountViewModel) - - Row( - modifier = - Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - CommentIcon(Size18Modifier, MaterialTheme.colorScheme.grayText) - Text( - text = - if (commentCount == 0) { - stringRes(R.string.podcast_comment_action) - } else { - pluralStringResource(R.plurals.podcast_comment_count, commentCount, commentCount) - }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.grayText, - ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 50c2232889..7c9e736205 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -155,6 +155,8 @@ fun PodcastHeader( accountViewModel = accountViewModel, nav = nav, ) + + PodcastTopSupporters(metadataNote, accountViewModel, nav) } // Only render once episodes have actually loaded — avoids flashing "0 episodes" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt new file mode 100644 index 0000000000..f642073f4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import java.math.BigDecimal + +private val Gold = Color(0xFFFFC300) +private val Silver = Color(0xFFB0B7C0) +private val Bronze = Color(0xFFCD7F32) + +/** + * "Top Supporters" leaderboard for a podcast show: aggregates the zaps on the show note into a + * sats-ranked list (reusing [LiveActivityTopZappersAggregator], the same engine as the live-stream + * leaderboard), top 3 flagged with gold/silver/bronze medals. Renders nothing until there's at least + * one zap. Matches PodStr's ZapLeaderboard, Nostr-native. + */ +@Composable +fun PodcastTopSupporters( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val zapState by observeNoteZaps(note, accountViewModel) + + val entries = + remember(zapState) { + val contributions = + note.zaps.mapNotNull { (_, receiptNote) -> + val receipt = receiptNote?.event as? LnZapEvent ?: return@mapNotNull null + val request = receipt.zapRequest ?: return@mapNotNull null + val sats = receipt.amount()?.toLong() ?: return@mapNotNull null + // Anon/private zaps carry an `anon` tag; collapse them into the shared bucket. + val isAnon = request.tags.any { it.isNotEmpty() && it[0] == "anon" } + ZapContribution(receiptNote.idHex, request.pubKey, isAnon, sats) + } + LiveActivityTopZappersAggregator.aggregate(contributions) + } + + if (entries.isEmpty()) return + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringRes(R.string.podcast_top_supporters), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + + entries.forEachIndexed { index, entry -> + SupporterRow(index, entry, accountViewModel, nav) + } + } +} + +@Composable +private fun SupporterRow( + index: Int, + entry: TopZapperEntry, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + RankBadge(index) + + if (entry.isAnonymous) { + Text( + text = stringRes(R.string.chat_zap_anonymous), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + } else { + LoadUser(entry.bucketKey, accountViewModel) { user -> + if (user != null) { + ClickableUserPicture(user, Size35dp, accountViewModel, onClick = { nav.nav(routeFor(it)) }) + UsernameDisplay(user, Modifier.weight(1f), accountViewModel = accountViewModel) + } else { + Text( + text = "", + modifier = Modifier.weight(1f), + ) + } + } + } + + ZapIcon(Size16Modifier, BitcoinOrange) + Text( + text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + } +} + +/** Gold/silver/bronze medal for the top 3, plain "#N" for the rest. */ +@Composable +private fun RankBadge(index: Int) { + val medal = + when (index) { + 0 -> Gold + 1 -> Silver + 2 -> Bronze + else -> null + } + + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (medal != null) { + Icon( + symbol = MaterialSymbols.MilitaryTech, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = medal, + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0d60765aa6..7ba32aceb8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1011,12 +1011,12 @@ Value-for-Value %1$d%% Zaps to this are split between: - Comment Hosts & Guests Play highlight - - %1$d comment - %1$d comments + Top Supporters + + %1$d chapter + %1$d chapters Host Co-host From 8085f82ca4195635afd1df9484f29327f1c0cec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:27:37 +0000 Subject: [PATCH 38/39] feat(podcasts): standard ReactionsRow on each episode in the Podcast screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the removed per-episode comment chip with the full NoteCompose ReactionsRow (comment / zap / react) on every episode row in a podcast's screen — same engagement affordance as the show header and every other note. addPadding = false so it aligns within the row's existing padding. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../loggedIn/podcasts/PodcastEpisodeListItem.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index d088629e95..a76a1a9796 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -40,6 +40,7 @@ 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 @@ -132,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, + ) } } From d723e93e7a15906505591ad25c6bea90e1b5c4a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:55:58 +0000 Subject: [PATCH 39/39] fix(quartz): drop commas from podcast test names for Kotlin/Native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin/Native (the iOS test target) rejects commas in backtick function names, so test-quartz-ios failed to compile even though jvmTest — which allows them — passed. Rename the two offending tests. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt | 2 +- .../com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt index 0f990e6aad..74fec4309a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt @@ -137,7 +137,7 @@ class PodcastPersonSoundbiteTest { } @Test - fun `show metadata without persons is empty, not a crash`() { + fun `show metadata without persons is empty rather than a crash`() { val event = AppSpecificDataEvent("id", "pk", 0, arrayOf(arrayOf("d", "podcast-metadata")), """{"title":"Bare"}""", "sig") val show = Podcasting20PodcastMetadata.parse(event) assertTrue(show?.showPersons().orEmpty().isEmpty()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt index 81d3ce1b10..391fac4952 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt @@ -45,7 +45,7 @@ class PodcastValueShareTest { } @Test - fun `fee recipient takes its split as a percent off the top, remainder split by weight`() { + fun `fee recipient takes its split as a percent off the top then remainder split by weight`() { val value = PodcastValue( recipients =