diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index a8a7f33572..d334023df1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 8b08ee63bd..4821a71ccc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -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() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt new file mode 100644 index 0000000000..a08d9f0925 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt @@ -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, + 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(), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt new file mode 100644 index 0000000000..00825d41a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt @@ -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, + 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" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 4d880a85ea..057706340c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index dfd388bf96..0d60765aa6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1012,6 +1012,8 @@ %1$d%% Zaps to this are split between: Comment + Hosts & Guests + Play highlight %1$d comment %1$d comments diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt index 670214c782..96be4d42e0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -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 = emptyList(), + soundbites: List = emptyList(), topics: List = 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt index 3607a898f4..bfe6c921bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt @@ -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.title(title: String) = addUnique(TitleTag.assemble(title)) @@ -58,6 +62,10 @@ fun TagArrayBuilder.chapters(url: String) = addUnique( fun TagArrayBuilder.value(value: PodcastValue) = addUnique(ValueTag.assemble(value)) +fun TagArrayBuilder.person(person: PodcastPerson) = add(PersonTag.assemble(person)) + +fun TagArrayBuilder.soundbite(soundbite: PodcastSoundbite) = add(SoundbiteTag.assemble(soundbite)) + fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) fun TagArrayBuilder.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt new file mode 100644 index 0000000000..4186262de8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt @@ -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", "", "", "", ""]`. + * + * 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): 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 { + // 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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt new file mode 100644 index 0000000000..cc104dca99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt @@ -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` 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): 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 { + val head = arrayOf(TAG_NAME, soundbite.startTimeSeconds.toString(), soundbite.durationSeconds.toString()) + val title = soundbite.title + return if (title.isNullOrEmpty()) head else head + title + } + } +} 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 61a8030ad9..3129ed1edd 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 @@ -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 = emptyList(), ) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt index 46a87e606e..c0fd1cc3d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt @@ -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 = 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 = emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt new file mode 100644 index 0000000000..f6914f9bd3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt @@ -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 `` 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() +} 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 9aca7484bd..dcf87a2f8a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt @@ -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 = emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt new file mode 100644 index 0000000000..e88cb2fbc8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt @@ -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", "", "", ""]` 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() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt new file mode 100644 index 0000000000..536038f965 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt @@ -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()) + } +}