mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+151
-7
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+33
-6
@@ -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
|
||||
}
|
||||
|
||||
+30
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user