mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
@@ -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() }
|
||||
|
||||
+9
@@ -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"
|
||||
}
|
||||
+4
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
+16
@@ -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()
|
||||
|
||||
+8
@@ -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))
|
||||
|
||||
+59
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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()
|
||||
}
|
||||
+131
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user