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