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