feat: surface richer podcast show metadata with a modern card

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-06-27 22:08:03 +00:00
parent 76b05bbb85
commit 7ea8af973d
5 changed files with 240 additions and 17 deletions
@@ -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,
)
}
}
+5
View File
@@ -915,6 +915,11 @@
<string name="podcast_no_episodes">No episodes found yet</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_trailer_season">Season %1$d</string>
<string name="podcast_explicit">Explicit</string>
<string name="podcast_completed">Completed</string>
<string name="podcast_premium">Premium</string>
<string name="podcast_support_show">Support the show</string>
<string name="podcast_by_author">by %1$s</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d episode</item>
<item quantity="other">%1$d episodes</item>
@@ -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<String> = emptyList(),
val explicit: Boolean? = null,
val website: String? = null,
val copyright: String? = null,
val funding: List<String> = emptyList(),
val locked: Boolean? = null,
val type: String? = null,
val complete: Boolean? = null,
val guid: String? = null,
)
companion object {
@@ -47,4 +47,25 @@ interface PodcastShow {
/** Associated website URLs (possibly empty). */
fun showWebsites(): List<String>
/**
* Free-text author/host byline (not a Nostr pubkey), if the draft carries one. NIP-F4 models
* authors as pubkeys with roles instead, so it leaves this null.
*/
fun showAuthor(): String? = null
/** Genre/category labels (e.g. "Technology"), possibly empty. */
fun showCategories(): List<String> = emptyList()
/** Donation/funding page URLs (Podcasting 2.0 `funding`), possibly empty. */
fun showFundingUrls(): List<String> = emptyList()
/** Whether the show is flagged as explicit. */
fun showIsExplicit(): Boolean = false
/** Whether the show is marked complete/finished (no further episodes expected). */
fun showIsComplete(): Boolean = false
/** Copyright line, if provided. */
fun showCopyright(): String? = null
}
@@ -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