feat(podcasts): Podcasting-2.0 person credits and soundbites

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-07-01 16:31:33 +00:00
parent ec1ea54dcc
commit 429ec9177e
16 changed files with 626 additions and 0 deletions
@@ -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() }
@@ -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()
}
}
}
}
@@ -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<PodcastPerson>,
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(),
)
}
}
}
@@ -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<PodcastSoundbite>,
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"
}
@@ -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
+2
View File
@@ -1012,6 +1012,8 @@
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_zap_split_hint">Zaps to this are split between:</string>
<string name="podcast_comment_action">Comment</string>
<string name="podcast_hosts_and_guests">Hosts &amp; Guests</string>
<string name="podcast_play_soundbite">Play highlight</string>
<plurals name="podcast_comment_count">
<item quantity="one">%1$d comment</item>
<item quantity="other">%1$d comments</item>
@@ -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<PodcastPerson> = emptyList(),
soundbites: List<PodcastSoundbite> = emptyList(),
topics: List<String> = 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()
@@ -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<Podcasting20EpisodeEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
@@ -58,6 +62,10 @@ fun TagArrayBuilder<Podcasting20EpisodeEvent>.chapters(url: String) = addUnique(
fun TagArrayBuilder<Podcasting20EpisodeEvent>.value(value: PodcastValue) = addUnique(ValueTag.assemble(value))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.person(person: PodcastPerson) = add(PersonTag.assemble(person))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.soundbite(soundbite: PodcastSoundbite) = add(SoundbiteTag.assemble(soundbite))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds))
@@ -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", "<name>", "<role>", "<img>", "<href>"]`.
*
* 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<String>): 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<String> {
// 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()
}
}
}
@@ -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>", "<duration>", "<optional title>"]`.
*
* `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<String>): 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<String> {
val head = arrayOf(TAG_NAME, soundbite.startTimeSeconds.toString(), soundbite.durationSeconds.toString())
val title = soundbite.title
return if (title.isNullOrEmpty()) head else head + title
}
}
}
@@ -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<PodcastPerson> = emptyList(),
)
companion object {
@@ -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<PodcastPerson> = 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<PodcastSoundbite> = emptyList()
}
@@ -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 `<podcast:person>` 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()
}
@@ -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<PodcastPerson> = emptyList()
}
@@ -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", "<startTime>", "<duration>", "<optional title>"]` 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()
}
@@ -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())
}
}