Merge pull request #4135 from vitorpamplona/claude/relaxed-hawking-7otkl6

NIP-71 video interop, and a renderer for every NIP-51 list kind
This commit is contained in:
Vitor Pamplona
2026-09-22 09:23:33 -04:00
committed by GitHub
79 changed files with 4068 additions and 38 deletions
@@ -138,6 +138,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
@@ -316,6 +316,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.withoutClientTag
@@ -1209,6 +1210,12 @@ class Account(
}
}
suspend fun changeCaptionsEnabled(enabled: Boolean) {
if (settings.changeCaptionsEnabled(enabled)) {
sendNewAppSpecificData()
}
}
suspend fun changeVideoPlayerButtonItems(items: List<VideoPlayerButtonItem>) {
if (settings.changeVideoPlayerButtonItems(items)) {
sendNewAppSpecificData()
@@ -2309,7 +2316,15 @@ class Account(
val poll = cache.getOrCreateNote(event.id).toEventHint<PollEvent>()
if (poll != null) {
val template = PollResponseEvent.build(poll, responses)
// The poll decides how its answers are counted, so it decides which builder applies:
// a single-choice answer is the first tag only, and sending several would record
// whichever one the reader saw first.
val template =
when (event.pollType()) {
PollType.SINGLE_CHOICE ->
responses.firstOrNull()?.let { PollResponseEvent.buildSingleChoice(poll, it) } ?: return
PollType.MULTI_CHOICE -> PollResponseEvent.buildMultipleChoice(poll, responses)
}
val signedEvent = signer.sign(template)
@@ -410,6 +410,15 @@ class AccountSettings(
return false
}
fun changeCaptionsEnabled(enabled: Boolean): Boolean {
if (syncedSettings.videoPlayer.captionsEnabled.value != enabled) {
syncedSettings.videoPlayer.captionsEnabled.tryEmit(enabled)
saveAccountSettings()
return true
}
return false
}
fun changeAudioVisualizer(style: VisualizerStyle): Boolean {
if (syncedSettings.media.audioVisualizer.value != style) {
syncedSettings.media.audioVisualizer.tryEmit(style)
@@ -70,6 +70,7 @@ class AccountSyncedSettings(
val videoPlayer =
AccountVideoPlayerPreferences(
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
MutableStateFlow(internalSettings.videoPlayer.captionsEnabled),
)
val media =
AccountMediaPreferences(
@@ -118,7 +119,7 @@ class AccountSyncedSettings(
security.sendKind0EventsToLocalRelay.value,
security.addClientTag.value,
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value, videoPlayer.captionsEnabled.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats =
AccountChatPreferencesInternal(
@@ -208,6 +209,10 @@ class AccountSyncedSettings(
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
}
if (videoPlayer.captionsEnabled.value != syncedSettingsInternal.videoPlayer.captionsEnabled) {
videoPlayer.captionsEnabled.tryEmit(syncedSettingsInternal.videoPlayer.captionsEnabled)
}
val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer)
if (media.audioVisualizer.value != newAudioVisualizer) {
media.audioVisualizer.tryEmit(newAudioVisualizer)
@@ -254,6 +259,7 @@ class AccountReactionPreferences(
@Stable
class AccountVideoPlayerPreferences(
var buttonItems: MutableStateFlow<ImmutableList<VideoPlayerButtonItem>>,
val captionsEnabled: MutableStateFlow<Boolean>,
)
@Stable
@@ -107,6 +107,7 @@ enum class VideoPlayerAction {
Download,
PictureInPicture,
Cast,
Captions,
}
@Serializable
@@ -127,6 +128,7 @@ val DefaultVideoPlayerButtonItems =
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Captions, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
@@ -184,6 +186,10 @@ class AccountNavigationPreferencesInternal(
@Serializable
class AccountVideoPlayerPreferencesInternal(
var buttonItems: List<VideoPlayerButtonItem> = DefaultVideoPlayerButtonItems,
// Feed videos autoplay muted, so captions are what make them legible — on by default, and
// remembered once the viewer says otherwise. Defaulted rather than required so a blob written
// by a client that predates the field still decodes.
var captionsEnabled: Boolean = true,
)
@Serializable
@@ -70,6 +70,16 @@ fun GetVideoController(
// warm-pool fast path keeps position and buffered data intact.
val keepAlive = remember { mutableStateOf(true) }
// Captions can resolve *after* playback starts: a `text-track` tag naming a kind-39307
// coordinate is only turned into a SubtitleConfiguration once that event arrives, which
// rebuilds `mediaItem` with the same videoUri and a different subtitle list. The mediaId is
// the video URI either way, so the warm-pool fast path below would read "already loaded" and
// the new tracks would never reach the player. A MediaController doesn't carry the item's
// `localConfiguration` across the service IPC, so the subtitles can't be read back off the
// controller to compare — we remember what we last pushed instead. Deliberately keyless so it
// survives the keyed remember below being rebuilt by the very change it has to detect.
val lastAppliedCaptions = remember { mutableStateOf<Int?>(null) }
val controllerState by remember(mediaItem, keepAlive.value) {
if (keepAlive.value) {
PlaybackServiceClient
@@ -118,6 +128,7 @@ fun GetVideoController(
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val captionsFingerprint = mediaItem.src.captions.hashCode()
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
// Cold load: a fresh decoder/codec instance gets allocated here. If a
@@ -127,6 +138,20 @@ fun GetVideoController(
Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" }
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (lastAppliedCaptions.value != null && lastAppliedCaptions.value != captionsFingerprint) {
// Same video, new caption set, and this composable is the one that loaded
// it: re-set the item so the side-loaded subtitle sources get merged in.
// Restarting from the current position keeps the rebuffer down to whatever
// the cache can't serve. The `!= null` guard is what keeps a warm player
// that arrived already carrying someone else's item from being re-prepared
// on a fingerprint we never pushed — that's the rebuffer the pool exists
// to avoid, and its captions are whatever that mount applied.
val resumeAt = state.controller.currentPosition
Log.d("PlaybackService") { "Captions changed for $targetMediaId - re-applying at $resumeAt" }
state.controller.setMediaItem(mediaItem.item, resumeAt)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.Fullscree
import com.vitorpamplona.amethyst.service.playback.composable.controls.LogVideoQualitySelection
import com.vitorpamplona.amethyst.service.playback.composable.controls.METERED_MAX_SHORT_SIDE_PX
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCaptions
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
@@ -217,6 +218,15 @@ fun RenderVideoPlayer(
shutter = {},
)
// Above the picture but below the controls, so the transport bar and the bottom gradient
// draw over the cues rather than the other way around.
if (mediaItem.src.captions.isNotEmpty()) {
RenderCaptions(
controllerState = controllerState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
RenderPlaybackError(
controllerState = controllerState,
videoUri = mediaItem.src.videoUri,
@@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
@@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
class WaveformData(
@@ -73,6 +76,7 @@ fun VideoView(
thumbhash: String? = null,
isLiveStream: Boolean = false,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val borderModifier =
if (roundedCorner) {
@@ -83,7 +87,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash, captions = captions)
}
@Composable
@@ -107,6 +111,7 @@ fun VideoView(
accountViewModel: AccountViewModel,
thumbhash: String? = null,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
// Reset the manual-show toggle when the video URI changes so a recycled feed slot
@@ -180,6 +185,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -236,6 +242,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -26,10 +26,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val DEFAULT_MUTED_SETTING = mutableStateOf(true)
@@ -57,6 +60,7 @@ fun VideoViewInner(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
accountViewModel: AccountViewModel,
) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -83,6 +87,7 @@ fun VideoViewInner(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -0,0 +1,157 @@
/*
* 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.service.playback.composable.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Locale
/**
* The text tracks a player has, in the order the media declared them.
*
* A NIP-71 video can carry a `text-track` per language, so "captions" is not one thing to switch
* on — it is a list to choose from. Groups the player reports as unsupported are dropped: offering
* a language the decoder will refuse is worse than not offering it.
*/
@OptIn(UnstableApi::class)
internal fun getTextTrackChoices(tracks: Tracks): ImmutableList<CaptionChoice> {
val choices = mutableListOf<CaptionChoice>()
tracks.groups
.filter { it.type == C.TRACK_TYPE_TEXT }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
choices.add(
CaptionChoice(
group = group,
trackIndex = i,
language = format.language,
label = format.label,
isSelected = group.isTrackSelected(i),
),
)
}
}
return choices.toImmutableList()
}
internal data class CaptionChoice(
val group: Tracks.Group,
val trackIndex: Int,
val language: String?,
val label: String?,
val isSelected: Boolean,
) {
/**
* What to print for this track.
*
* The publisher's own label wins when there is one. Otherwise the language code is expanded
* into the reader's locale — "en" is not a word to most people, "English" is — and only a
* track that names neither falls back to a generic term.
*/
fun displayName(fallback: String): String =
label?.takeIf { it.isNotBlank() }
?: language?.takeIf { it.isNotBlank() }?.let { Locale.forLanguageTag(it).displayLanguage.ifBlank { it } }
?: fallback
}
@OptIn(UnstableApi::class)
internal fun selectTextTrack(
player: Player,
choice: CaptionChoice,
) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
// Clearing the type disable as well: picking a language is a request to see captions,
// so it has to undo an earlier "off" rather than silently select a track nothing draws.
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(TrackSelectionOverride(choice.group.mediaTrackGroup, choice.trackIndex))
.build()
}
/**
* The language menu, shown in place of a plain on/off toggle when a video offers more than one
* caption track.
*/
@Composable
internal fun CaptionLanguagePopup(
choices: ImmutableList<CaptionChoice>,
captionsEnabled: Boolean,
onSelectOff: () -> Unit,
onSelectTrack: (CaptionChoice) -> Unit,
onDismiss: () -> Unit,
) {
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
val baseColors = ButtonDefaults.textButtonColors()
val contentColor = MaterialTheme.colorScheme.onBackground
val colors = remember(baseColors, contentColor) { baseColors.copy(contentColor = contentColor) }
val unnamed = stringRes(R.string.captions_unnamed_track)
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextButton(colors = colors, onClick = onSelectOff) {
Text(
stringRes(R.string.captions_off),
fontWeight = if (!captionsEnabled) FontWeight(1000) else FontWeight(400),
)
}
choices.forEach { choice ->
TextButton(colors = colors, onClick = { onSelectTrack(choice) }) {
Text(
choice.displayName(unnamed),
fontWeight = if (captionsEnabled && choice.isSelected) FontWeight(1000) else FontWeight(400),
)
}
}
}
}
}
@@ -79,6 +79,9 @@ fun OverflowMenuButtonPreview() {
onCastClick = {},
castIcon = MaterialSymbols.Cast,
castContentDescription = "",
onCaptionsClick = {},
captionsIcon = MaterialSymbols.ClosedCaption,
captionsContentDescription = "",
)
}
}
@@ -98,6 +101,9 @@ fun AnimatedOverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@@ -118,6 +124,9 @@ fun AnimatedOverflowMenuButton(
onCastClick = onCastClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
}
@@ -135,6 +144,9 @@ fun OverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
) {
val menuExpanded = remember { mutableStateOf(false) }
@@ -239,6 +251,16 @@ fun OverflowMenuButton(
onCastClick()
}
}
VideoPlayerAction.Captions -> {
M3ActionRow(
icon = captionsIcon,
text = captionsContentDescription,
) {
menuExpanded.value = false
onCaptionsClick()
}
}
}
}
}
@@ -0,0 +1,103 @@
/*
* 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.service.playback.composable.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
/**
* Draws the cues of the currently selected text track over the player.
*
* media3's Compose UI has no subtitle view — that lives in the View-based `PlayerView`, which this
* app does not use — so the cues are collected from the player and laid out here. Only the cue
* text is honoured: a WebVTT region/position would need the player's video rect, and every
* NIP-71 caption seen in the wild is plain bottom-centred dialogue.
*
* Nothing renders when no text track is selected, so this composable costs one listener on videos
* without captions.
*/
@Composable
fun RenderCaptions(
controllerState: MediaControllerState,
modifier: Modifier = Modifier,
) {
val player = controllerState.controller
var lines by remember(player) { mutableStateOf<List<String>>(emptyList()) }
DisposableEffect(player) {
lines =
player.currentCues.cues
.mapNotNull { it.text?.toString() }
.filter { it.isNotBlank() }
val listener =
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
lines = cueGroup.cues.mapNotNull { it.text?.toString() }.filter { it.isNotBlank() }
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
if (lines.isEmpty()) return
Column(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
lines.forEach { line ->
Text(
text = line,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
// A scrim behind the glyphs rather than a full-width bar: captions sit over the
// picture, and a translucent box only where there is text keeps the frame visible.
modifier =
Modifier
.background(CaptionScrim, MaterialTheme.shapes.extraSmall)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
private val CaptionScrim = Color.Black.copy(alpha = 0.6f)
@@ -46,6 +46,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -98,6 +99,8 @@ fun RenderTopButtonsPreview() {
onPictureInPictureClick = {},
onZoomClick = {},
onOverflowQualityClick = {},
captionsEnabled = true,
onCaptionsClick = {},
modifier = Modifier,
accountViewModel = mockAccountViewModel(),
)
@@ -143,6 +146,43 @@ fun RenderTopButtons(
val videoGroup = getVideoTrackGroup(tracks)
val hasMultipleQualities = videoGroup != null && videoGroup.length > 1
// Captions are side-loaded from the event's `text-track` tags (MediaItemCache flags the first
// one default). The account setting is the source of truth, not the player: a player comes
// out of the warm pool carrying whatever track selection the previous video left on it, so
// reading the preference off the instance would make the button's state depend on which
// player this video happened to be handed.
val captionsEnabled by accountViewModel.captionsEnabledFlow().collectAsStateWithLifecycle()
// Push the preference onto whichever player is attached, and re-push when either changes.
// Disabling the whole track type rather than deselecting one group: the type-level switch
// survives the player later picking a different track.
LaunchedEffect(player, captionsEnabled) {
val alreadyDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)
if (alreadyDisabled == captionsEnabled) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !captionsEnabled)
.build()
}
}
// A video with one track has nothing to choose between, so the button stays a toggle. Only a
// multi-language video opens a menu — paying a popup for a single "English" row would be a
// worse answer to the same tap.
val captionChoices = remember(tracks) { getTextTrackChoices(tracks) }
val captionsPopupOpen = remember { mutableStateOf(false) }
val onCaptionsClick =
remember(captionsEnabled, captionChoices) {
{
if (captionChoices.size > 1) {
captionsPopupOpen.value = true
} else {
accountViewModel.setCaptionsEnabled(!captionsEnabled)
}
}
}
val overflowQualityOpen = remember { mutableStateOf(false) }
// Pause local playback while this video is casting so audio doesn't
@@ -210,10 +250,31 @@ fun RenderTopButtons(
}
},
onOverflowQualityClick = { overflowQualityOpen.value = true },
captionsEnabled = captionsEnabled,
onCaptionsClick = onCaptionsClick,
modifier = modifier,
accountViewModel = accountViewModel,
)
if (captionsPopupOpen.value) {
CaptionLanguagePopup(
choices = captionChoices,
captionsEnabled = captionsEnabled,
onSelectOff = {
accountViewModel.setCaptionsEnabled(false)
captionsPopupOpen.value = false
},
onSelectTrack = { choice ->
// The override goes straight onto the player (it is per-video), while the
// preference is what persists the fact that captions are wanted at all.
selectTextTrack(player, choice)
accountViewModel.setCaptionsEnabled(true)
captionsPopupOpen.value = false
},
onDismiss = { captionsPopupOpen.value = false },
)
}
if (overflowQualityOpen.value && videoGroup != null) {
VideoQualityPopup(
player = player,
@@ -237,10 +298,15 @@ fun RenderTopButtons(
onPictureInPictureClick: () -> Unit,
onZoomClick: (() -> Unit)?,
onOverflowQualityClick: () -> Unit,
captionsEnabled: Boolean,
onCaptionsClick: () -> Unit,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val captionsIcon = if (captionsEnabled) MaterialSymbols.ClosedCaption else MaterialSymbols.ClosedCaptionDisabled
val captionsContentDescription =
stringRes(if (captionsEnabled) R.string.captions_turn_off else R.string.captions_turn_on)
val shareDialogVisible = remember { mutableStateOf(false) }
val castDialogVisible = remember { mutableStateOf(false) }
val castSessionState by Amethyst.instance.castRegistry.sessionState
@@ -297,21 +363,28 @@ fun RenderTopButtons(
VideoPlayerAction.Cast -> {
BuildConfig.IS_CASTING_AVAILABLE && mediaData.videoUri.startsWith("http", ignoreCase = true)
}
// A video with no `text-track` has nothing to toggle, so the button stays out of the
// row entirely rather than sitting there inert.
VideoPlayerAction.Captions -> {
mediaData.captions.isNotEmpty()
}
}
val canFullscreen = onZoomClick != null
val hasCaptions = mediaData.captions.isNotEmpty()
// ImmutableList so Compose can treat the action lists as stable parameters when they're
// passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the
// overflow tree to recompose whenever any unrelated parent state ticks.
val topBarActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
.map { it.action }
.toImmutableList()
}
val overflowActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }
.map { it.action }
@@ -377,6 +450,15 @@ fun RenderTopButtons(
onClick = onCastButtonClick,
)
}
VideoPlayerAction.Captions -> {
AnimatedTopBarIconButton(
controllerVisible = controllerVisible,
symbol = captionsIcon,
contentDescription = captionsContentDescription,
onClick = onCaptionsClick,
)
}
}
}
@@ -394,6 +476,9 @@ fun RenderTopButtons(
onCastClick = onCastButtonClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
@@ -21,11 +21,16 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val mediaItemCache = MediaItemCache()
@@ -46,10 +51,21 @@ fun GetMediaItem(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val data =
remember(videoUri) {
// Captions join the key. They resolve asynchronously — a `text-track` that names a
// kind-39307 coordinate has to reach LocalCache first — so keying on the URI alone would
// lock in the caption-less item built on the first frame. The list only ever goes empty ->
// resolved, so this rebuilds at most once.
//
// What that rebuild reaches depends on when it lands. A feed video is mounted long before
// it is scrolled into view and played, so the item carrying the tracks is the one the
// controller loads. For a video already playing, GetVideoController's warm-path check sees
// the same mediaId and deliberately does not re-set the item, so the late tracks wait for
// the next mount rather than restarting playback under the viewer.
remember(videoUri, captions) {
MediaItemData(
videoUri = videoUri,
authorName = authorName,
@@ -66,6 +82,7 @@ fun GetMediaItem(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
)
}
@@ -79,7 +96,19 @@ fun GetMediaItem(
) {
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem?.let {
// produceCachedState re-remembers on a new key and starts at null while the replacement is
// built off-thread. Rendering that null would drop the whole player subtree for a frame and
// rebuild it — a visible blink — and for the one case that changes the key mid-playback
// (captions resolving after the first frame) the warm-pool check then declines to re-set an
// item carrying the same mediaId, so the blink would buy nothing. Keep drawing the item we
// already have until its replacement exists.
val previous = remember { mutableStateOf<LoadedMediaItem?>(null) }
val current = mediaItem
if (current != null && previous.value !== current) {
SideEffect { previous.value = current }
}
(current ?: previous.value)?.let {
inner(it)
}
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import android.os.Bundle
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -61,6 +63,25 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
return null
}
// A side-loaded track needs its media type declared: a NIP-71 `text-track` says nothing
// about the format, and a blossom-hosted one is `https://host/<sha256>` with no extension
// for ExoPlayer to infer from either. A track flagged default is the one ExoPlayer
// selects without being asked.
internal fun CaptionTrack.toSubtitleConfiguration(isDefault: Boolean = false): MediaItem.SubtitleConfiguration =
MediaItem.SubtitleConfiguration
.Builder(url.toUri())
.setMimeType(mimeType)
.setLanguage(language)
.setLabel(label())
.setSelectionFlags(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0)
.build()
private fun CaptionTrack.label(): String? =
when {
language != null && type != null -> "$language \u00B7 $type"
else -> language ?: type
}
// Restrict `.m3u8` matching to the path component so a query param or
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
// MP4 URI doesn't misroute to HlsMediaSource.
@@ -83,7 +104,15 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.apply { normalizedMime?.let { setMimeType(it) } }
.setMediaMetadata(
.setSubtitleConfigurations(
// Flagging the first track default is what decides WHICH track plays
// once text is enabled. WHETHER it is enabled is the account's
// captionsEnabled preference, which RenderTopButtons pushes onto the
// player as a track-type switch — the two are independent.
key.captions.mapIndexed { index, track ->
track.toSubtitleConfiguration(isDefault = index == 0)
},
).setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
@@ -22,8 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class MediaItemData(
@@ -48,6 +51,9 @@ data class MediaItemData(
val dim: DimensionTag? = null,
val hash: String? = null,
val thumbhash: String? = null,
// NIP-71 `text-track` captions to side-load. A data class is the cache key, so this has to be
// a value that compares by content — ImmutableList keeps that true and keeps the key @Stable.
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
)
@Immutable
@@ -29,6 +29,8 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
@@ -104,6 +106,7 @@ class CustomMediaSourceFactory(
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
private val plainDataSource: DataSource.Factory = dataSourceFactory
private val cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(cachingDataSource)
@@ -119,9 +122,10 @@ class CustomMediaSourceFactory(
//
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
// be mirrored here.
// live target-offset defaults. Subtitles ARE reachable now (MediaItemCache side-loads NIP-71
// `text-track` captions), so createMediaSource below mirrors that wrap for the HLS path;
// clipping and ad insertion remain unused, and the live setters aren't on the
// MediaSource.Factory interface. Anything else added later must be mirrored here too.
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
@@ -137,7 +141,13 @@ class CustomMediaSourceFactory(
return this
}
// Retained as well as forwarded: the subtitle sources in withSideLoadedSubtitles are built per
// call rather than living in allFactories, so this is the only way they can be given the same
// retry/backoff behaviour the player configured for everything else.
private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
return this
}
@@ -161,7 +171,16 @@ class CustomMediaSourceFactory(
} else {
if (bypassCache) nonCachingFactory else cachingFactory
}
val source = factory.createMediaSource(mediaItem)
// DefaultMediaSourceFactory is what merges side-loaded subtitle tracks into the playback
// source. The explicit HLS factory above does not, so on that path a caption track would
// load into nothing; mirror the wrap here. Progressive items already got it from the
// Default factory, so they must NOT be wrapped again.
val source =
if (hls) {
withSideLoadedSubtitles(factory.createMediaSource(mediaItem), mediaItem, bypassCache)
} else {
factory.createMediaSource(mediaItem)
}
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
// shouldBypassCache.
@@ -172,6 +191,38 @@ class CustomMediaSourceFactory(
return source
}
/**
* Rebuilds what [DefaultMediaSourceFactory] does for `subtitleConfigurations`: each side-loaded
* track becomes its own single-sample source, merged alongside the video.
*
* Subtitle files are small, immutable documents, so they follow the video's cache decision —
* a track fetched beside a cached VOD playlist is worth keeping too, and one beside a live
* stream is not worth a cache entry for a stream that is never replayed.
*/
private fun withSideLoadedSubtitles(
source: MediaSource,
mediaItem: MediaItem,
bypassCache: Boolean,
): MediaSource {
val subtitles = mediaItem.localConfiguration?.subtitleConfigurations.orEmpty()
if (subtitles.isEmpty()) return source
val dataSource = if (bypassCache) plainDataSource else cachingDataSource
val sources =
Array(subtitles.size + 1) { index ->
if (index == 0) {
source
} else {
SingleSampleMediaSource
.Factory(dataSource)
.apply { loadErrorHandlingPolicy?.let { setLoadErrorHandlingPolicy(it) } }
.createMediaSource(subtitles[index - 1], C.TIME_UNSET)
}
}
return MergingMediaSource(*sources)
}
// Only the explicit event-kind flag (kind:30311 live activities). Returns false when the flag
// is absent or false; the HLS URL check in createMediaSource covers everything else.
private fun isFlaggedLive(mediaItem: MediaItem): Boolean {
@@ -523,6 +523,7 @@ private fun RenderImageOrVideo(
controllerVisible = controllerVisible,
hasBlurhash = content.blurhash != null,
isFullscreen = true,
captions = content.captions,
accountViewModel = accountViewModel,
)
}
@@ -277,6 +277,7 @@ fun ZoomableContentView(
thumbhash = content.thumbhash,
isLiveStream = content.isLiveStream,
hash = content.hash,
captions = content.captions,
)
}
}
@@ -221,6 +221,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderThread
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
import com.vitorpamplona.amethyst.ui.note.types.RenderVideoCollaboration
import com.vitorpamplona.amethyst.ui.note.types.RenderVideoCurationSet
import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiMergeAcceptance
@@ -229,6 +231,25 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderWikiRedirect
import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderAppCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderArticleCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderFavoriteAlgoFeedsList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGeohashList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitAuthorList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitRepositoryList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiAuthorList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiRelayList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderHashtagList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderInterestSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderKindMuteSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderLabeledBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaFollowList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaStarterPack
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMuteList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderOldBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderPictureCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderSimpleGroupList
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
@@ -291,6 +312,7 @@ import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.experimental.ratings.RelayReviewEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -328,8 +350,26 @@ import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
@@ -338,6 +378,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -1579,6 +1621,14 @@ private fun RenderNoteRow(
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
}
is VideoCollaborationEvent -> {
RenderVideoCollaboration(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is VideoCurationSetEvent -> {
RenderVideoCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is PictureEvent -> {
PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
}
@@ -1788,6 +1838,82 @@ private fun RenderNoteRow(
)
}
is MuteListEvent -> {
RenderMuteList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is BookmarkListEvent -> {
RenderBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is OldBookmarkListEvent -> {
RenderOldBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is LabeledBookmarkListEvent -> {
RenderLabeledBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is ArticleCurationSetEvent -> {
RenderArticleCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is PictureCurationSetEvent -> {
RenderPictureCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AppCurationSetEvent -> {
RenderAppCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is GitRepositoryListEvent -> {
RenderGitRepositoryList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is FavoriteAlgoFeedsListEvent -> {
RenderFavoriteAlgoFeedsList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is GitAuthorListEvent -> {
RenderGitAuthorList(baseNote, backgroundColor, accountViewModel, nav)
}
is MediaFollowListEvent -> {
RenderMediaFollowList(baseNote, backgroundColor, accountViewModel, nav)
}
is GoodWikiAuthorListEvent -> {
RenderGoodWikiAuthorList(baseNote, backgroundColor, accountViewModel, nav)
}
is KindMuteSetEvent -> {
RenderKindMuteSet(baseNote, backgroundColor, accountViewModel, nav)
}
is MediaStarterPackEvent -> {
RenderMediaStarterPack(baseNote, backgroundColor, accountViewModel, nav)
}
is HashtagListEvent -> {
RenderHashtagList(baseNote, backgroundColor, accountViewModel, nav)
}
is InterestSetEvent -> {
RenderInterestSet(baseNote, backgroundColor, accountViewModel, nav)
}
is GeohashListEvent -> {
RenderGeohashList(baseNote, backgroundColor, accountViewModel)
}
is SimpleGroupListEvent -> {
RenderSimpleGroupList(baseNote, backgroundColor, accountViewModel)
}
is GoodWikiRelayListEvent -> {
RenderGoodWikiRelayList(baseNote, backgroundColor, accountViewModel)
}
else -> {
RenderTextEvent(
baseNote,
@@ -0,0 +1,76 @@
/*
* 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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.model.nip71Video.captionTracks
import com.vitorpamplona.amethyst.commons.model.nip71Video.mergeCaptionTracks
import com.vitorpamplona.amethyst.commons.model.nip71Video.toCaptionTrack
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Resolves the caption tracks a NIP-71 video advertises, fetching the addressable ones.
*
* A `text-track` that names a URL is usable immediately. One that names a
* `39307:<pubkey>:<d>` coordinate is not, so it gets an [observeNoteEvent] of its own — that both
* reads the event out of LocalCache and puts it on an EventFinder subscription, so a track the
* device has never seen is requested from relays and appears when it lands.
*
* The same track is routinely advertised both ways at once (divine.video publishes the Blossom URL
* and its wrapping event side by side), so the two are merged by URL: the player is handed one
* configuration per distinct file, never a duplicate pair it would offer twice in its track menu.
*/
@Composable
fun rememberCaptionTracks(
videoEvent: VideoEvent,
accountViewModel: AccountViewModel,
): ImmutableList<CaptionTrack> {
val refs = remember(videoEvent) { videoEvent.captionTracks() }
// No early return: the pending list changes size when a recycled feed slot switches videos,
// and `key` is what lets Compose add and drop those child subscriptions in place.
val resolved =
refs.pending.map { pending ->
key(pending.address.toValue()) {
val note = remember(pending.address) { accountViewModel.getOrCreateAddressableNote(pending.address) }
val trackEvent by observeNoteEvent<TextTrackEvent>(note, accountViewModel)
trackEvent?.toCaptionTrack(pending.tag)
}
}
return remember(refs, resolved) {
if (refs.direct.isEmpty() && resolved.all { it == null }) {
persistentListOf()
} else {
mergeCaptionTracks(refs.direct, resolved.filterNotNull()).toImmutableList()
}
}
}
@@ -0,0 +1,97 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
/**
* A collaborator's acceptance (or refusal) of a credit, rendered on its own.
*
* The event carries no content — everything it says is in the video it points at — so the video is
* embedded and the event itself is reduced to one line of context above it: the role the
* collaborator was given, and whether they took it.
*/
@Composable
fun RenderVideoCollaboration(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? VideoCollaborationEvent ?: return
val video = remember(noteEvent) { noteEvent.video() }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val headline =
if (noteEvent.isAccepted()) {
stringRes(R.string.video_collaboration_accepted_on)
} else {
stringRes(R.string.video_collaboration_declined_on)
}
val role = remember(noteEvent) { noteEvent.role() }
Text(
text = if (role != null) "$headline · $role" else headline,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// quotesLeft guards the recursion: the embedded video can itself credit other work, and
// NoteCompose is what decrements it on the way down.
if (video != null && quotesLeft > 0) {
LoadAddressableNote(video, accountViewModel) { videoNote ->
videoNote?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
@@ -0,0 +1,248 @@
/*
* 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.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.credits.CreditTarget
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit
/**
* The line of credits under a video: who it features, who inspired it, whose audio it reuses.
*
* Every entry is a link, because the credit is only worth printing if it takes you to the person
* or the work. A credited person who has published a [VideoCollaborationEvent] for this video gets
* a check beside their name — they are not merely tagged, they took the credit.
*
* A `p` tag marked `mention` is kept even though the description may name the same person: on
* divine.video the description writes a plain `@handle`, which no client can turn into a link, so
* the tag is the only thing that resolves to an actual profile. The cost is a chip that repeats a
* `nostr:` mention already rendered inline, which is redundancy rather than a wrong credit.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderVideoCredits(
videoEvent: VideoEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
val credits = remember(videoEvent) { videoEvent.credits() }
if (credits.isEmpty()) return
// Only an addressable video has a coordinate for a collaborator to answer, so only there can
// an acceptance be looked up at all.
val videoAddress = remember(videoEvent) { (videoEvent as? AddressableEvent)?.address() }
FlowRow(modifier = HalfTopPadding) {
credits.forEach { credit ->
key(credit.target) {
RenderVideoCredit(credit, videoAddress, accountViewModel, nav)
}
}
}
}
@Composable
private fun RenderVideoCredit(
credit: VideoCredit,
videoAddress: Address?,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (val target = credit.target) {
is CreditTarget.Person ->
LoadUser(target.pubKey, accountViewModel) { user ->
// Until the profile arrives there is no name to print, and a hex key in the
// credits reads as noise rather than as a person.
user?.let {
CreditRow(
label = personLabel(credit.label),
// toBestDisplayName() is read inside the row so a profile that lands later
// still renames the credit.
name = "@" + it.toBestDisplayName(),
confirmed = {
// Only a role-marked credit is an invite someone can accept, and each
// lookup registers a cache observer per credit per rendered card.
// Watching every `mention` and `inspired-by` too would spend one per
// feed item on an event that cannot exist.
if (credit.label.isCollaborationRole()) {
AcceptedCollaboration(target.pubKey, videoAddress, accountViewModel)
}
},
onClick = { nav.nav(Route.Profile(target.pubKey)) },
)
}
}
is CreditTarget.Video ->
CreditRow(
label = workLabel(credit.label),
name = target.address.dTag.take(SHORT_REF_LENGTH),
onClick = {
nav.nav(Route.Note(target.address.toTag()))
},
)
is CreditTarget.Event ->
CreditRow(
label = workLabel(credit.label),
name = target.eventId.take(SHORT_REF_LENGTH),
onClick = { nav.nav(Route.Note(target.eventId)) },
)
}
}
/**
* Draws a check when [pubKey] has published an accepted [VideoCollaborationEvent] for this video.
*
* The response is matched by what both shapes in the wild agree on — the `a` tag naming the video
* — rather than by the response's own coordinate. divine-mobile keys the event by the video
* coordinate (so `34238:<collaborator>:<video coordinate>` would resolve it) but divine-web writes
* a random `d`, and asking for a coordinate nobody can predict found nothing for the client that
* publishes most of these. The relay side is covered by the `a` engagement filter every rendered
* addressable note already opens (see PostsAndChatMessagesToAddresses), so this reads the cache
* and costs no subscription of its own.
*
* Newest-first, so a collaborator who accepted and later withdrew reads as withdrawn.
*/
@Composable
private fun AcceptedCollaboration(
pubKey: String,
videoAddress: Address?,
accountViewModel: AccountViewModel,
) {
if (videoAddress == null) return
val responseFlow =
remember(pubKey, videoAddress) {
accountViewModel.account.cache.observeLatestEvent<VideoCollaborationEvent>(
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(pubKey),
tags = mapOf("a" to listOf(videoAddress.toValue())),
limit = 1,
),
)
}
val response by responseFlow.collectAsStateWithLifecycle(null)
if (response?.isAccepted() == true) {
Icon(
symbol = MaterialSymbols.CheckCircle,
contentDescription = stringRes(R.string.video_credit_accepted),
modifier = Modifier.padding(start = 2.dp),
tint = MaterialTheme.colorScheme.lessImportantLink,
)
}
}
@Composable
private fun CreditRow(
label: String,
name: String,
confirmed: @Composable () -> Unit = {},
onClick: () -> Unit,
) {
Row(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (label.isNotBlank()) {
Text(
text = "$label ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
ClickableTextColor(
text = name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
linkColor = MaterialTheme.colorScheme.lessImportantLink,
onClick = onClick,
)
confirmed()
}
}
// A person tagged without a stated capacity is still in the video — that is what NIP-71's `p`
// means — so the neutral credit, not a blank one, is the honest default.
@Composable
private fun personLabel(marker: String?): String =
when (marker) {
INSPIRED_BY_MARKER -> stringRes(R.string.video_credit_inspired_by)
MENTION_MARKER, null -> stringRes(R.string.video_credit_featuring)
// Anything else is a role the video's author typed ("Collaborator", "Director"): print it.
else -> marker
}
// The same treatment for a credited work. Both reference kinds route through here so an `e` tag
// and an `a` tag carrying the same marker read identically — the `e` path used to print the raw
// protocol token, so a `mention` showed up in the UI as the literal word "mention".
@Composable
private fun workLabel(marker: String?): String =
when (marker) {
AUDIO_MARKER -> stringRes(R.string.video_credit_audio_from)
MENTION_MARKER, null -> stringRes(R.string.video_credit_references)
else -> marker
}
/** True for a marker that names a role rather than describing a reference. */
private fun String?.isCollaborationRole(): Boolean = this != null && this != MENTION_MARKER && this != INSPIRED_BY_MARKER && this != AUDIO_MARKER
private const val INSPIRED_BY_MARKER = "inspired-by"
private const val MENTION_MARKER = "mention"
private const val AUDIO_MARKER = "audio"
// Enough of a hex id or `d` tag to tell two references apart without wrapping the row.
private const val SHORT_REF_LENGTH = 8
@@ -0,0 +1,217 @@
/*
* 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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.utils.Log
/**
* A NIP-51 kind-30005 video list: its name, what it is for, and a strip of its videos.
*
* Members may be public tags or NIP-51 private ones inside the encrypted content — divine.video
* puts every member there, so for anyone but the list's owner the strip is legitimately empty and
* the card says so rather than looking broken.
*/
@Composable
fun RenderVideoCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? VideoCurationSetEvent ?: return
val publicItems = remember(noteEvent) { noteEvent.publicItems() }
// Decryption needs the signer and only succeeds for the list's owner, so it runs off the
// composition and simply yields nothing for everyone else.
//
// Null means "we did not read the content": not the owner, or the decrypt failed. An empty
// list means we read it and it holds nothing. The two have to stay apart, because a list whose
// encrypted content decrypts to `[]` is an empty list, not a private one — divine.video
// publishes exactly that for a list a user created and never added to.
val privateItems by
produceState<List<BookmarkIdTag>?>(initialValue = null, noteEvent) {
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
value =
try {
noteEvent.privateItems(accountViewModel.account.signer)
} catch (e: Exception) {
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
Log.w("RenderVideoCurationSet", "Cannot decrypt private items of ${noteEvent.id}", e)
null
}
}
val items = remember(publicItems, privateItems) { publicItems + (privateItems ?: emptyList()) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
(noteEvent.title() ?: noteEvent.dTag()).takeIf { it.isNotBlank() }?.let {
Text(
text = it,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
noteEvent.description()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
// An empty strip has two very different causes. Encrypted content we could not read means
// the members may be there and private; content we did read, or no content at all, means
// the list is simply empty. Calling that "Private list" would be a lie — to its own author
// most of all, who just decrypted it and found nothing in it.
val membersAreHidden = items.isEmpty() && noteEvent.content.isNotBlank() && privateItems == null
Text(
text =
if (membersAreHidden) {
stringRes(R.string.video_curation_set_private_items)
} else {
pluralStringResource(R.plurals.video_curation_set_video_count, items.size, items.size)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (items.isNotEmpty() && quotesLeft > 0) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items.take(MAX_POSTERS).forEach { item ->
key(item.toTagIdOnly().joinToString(":")) {
VideoListMemberPoster(item, accountViewModel, nav)
}
}
}
}
}
}
/** One video in the strip: its poster, or its blurhash until that loads. */
@Composable
private fun VideoListMemberPoster(
item: BookmarkIdTag,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (item) {
is AddressBookmark ->
LoadAddressableNote(item.address, accountViewModel) { note ->
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(addressTag(item.address))) } }
}
is EventBookmark ->
LoadNote(item.eventId, accountViewModel) { note ->
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(item.eventId)) } }
}
}
}
@Composable
private fun VideoPoster(
note: Note,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val videoEvent = note.event as? VideoEvent ?: return
// The poster, not the video: a strip of simultaneously-playing players is unusable, and every
// NIP-71 imeta that has a thumbnail carries it as `image`.
val imeta = remember(videoEvent) { videoEvent.imetaTags().firstOrNull { it.image.isNotEmpty() } ?: videoEvent.imetaTags().firstOrNull() }
val poster = imeta?.image?.firstOrNull()
val tileModifier = Modifier.width(POSTER_WIDTH).height(POSTER_HEIGHT).clickable(onClick = onClick)
if (poster == null) {
imeta?.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) }
return
}
MyAsyncImage(
imageUrl = poster,
contentDescription = videoEvent.title(),
contentScale = ContentScale.Crop,
mainImageModifier = tileModifier,
loadedImageModifier = MaterialTheme.colorScheme.imageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { imeta.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) } },
onError = null,
)
}
private fun addressTag(address: Address) = Address.assemble(address.kind, address.pubKeyHex, address.dTag)
// Enough to show what kind of list this is without turning a feed card into a gallery.
private const val MAX_POSTERS = 8
private val POSTER_WIDTH = 96.dp
private val POSTER_HEIGHT = 128.dp
@@ -86,9 +86,12 @@ fun VideoDisplay(
val image = remember(imeta) { imeta.image.firstOrNull() }
val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") }
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val captions = rememberCaptionTracks(videoEvent, accountViewModel)
val content: BaseMediaContent =
remember(note) {
// Keyed on the captions too: an addressable `text-track` resolves after the first frame,
// and the player only picks up a track that is in the MediaItemData it was handed.
remember(note, captions) {
val description = videoEvent.content.ifBlank { null } ?: event.alt()
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
@@ -118,6 +121,7 @@ fun VideoDisplay(
mimeType = imeta.mimeType,
blurhash = imeta.blurhash,
thumbhash = imeta.thumbhash,
captions = captions,
)
}
}
@@ -200,6 +204,11 @@ fun VideoDisplay(
}
}
}
// Outside the summary block on purpose: divine.video shorts routinely credit a
// soundtrack or a collaborator while leaving the description empty, and those credits
// are the only attribution the reused work gets.
RenderVideoCredits(videoEvent, accountViewModel, nav)
}
}
}
@@ -0,0 +1,148 @@
/*
* 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.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
/** NIP-51 kind 10018: the git repositories this user follows. */
@Composable
fun RenderGitRepositoryList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GitRepositoryListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicRepositories() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRepositories(it) }
ListCard(
title = stringRes(R.string.kind_git_repositories),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { repo ->
key(repo.address.toValue()) {
AddressMemberRow(repo.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/** NIP-51 kind 10090: the DVM content feeds this user keeps to hand. */
@Composable
fun RenderFavoriteAlgoFeedsList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? FavoriteAlgoFeedsListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicFavoriteAlgoFeeds() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFavoriteAlgoFeeds(it) }
ListCard(
title = stringRes(R.string.kind_favorite_algo_feeds),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { feed ->
key(feed.address.toValue()) {
AddressMemberRow(feed.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/** NIP-51 kind 30267: "references to multiple software applications". */
@Composable
fun RenderAppCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? AppCurationSetEvent ?: return
val apps = remember(noteEvent) { noteEvent.apps() }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_app_curation_set),
description = noteEvent.description(),
items = apps,
// Not a PrivateTagArrayEvent: an app set has no encrypted half to be locked out of.
hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor,
) { app ->
key(app.address.toValue()) {
AddressMemberRow(app.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/**
* NIP-51 kind 10009: the simple groups this user belongs to.
*
* A group is named by an id on a relay rather than by a coordinate, so it gets its own row: the
* group's name when it carries one, and the relay that hosts it underneath.
*/
@Composable
fun RenderSimpleGroupList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? SimpleGroupListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicGroups() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateGroups(it) }
ListCard(
title = stringRes(R.string.kind_simple_groups),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { group ->
key(group.groupId + group.relayUrl) {
LabelMemberRow(group.name?.takeIf { it.isNotBlank() } ?: group.groupId)
}
}
}
@@ -0,0 +1,206 @@
/*
* 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.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent
/** NIP-51 kind 10003: the user's bookmarks — notes, articles, hashtags and links. */
@Composable
fun RenderBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? BookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), null, R.string.kind_bookmark_list),
description = null,
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* NIP-51 kind 30001: the deprecated bookmark set. Still rendered, because the events are on relays
* whether or not the spec wants them to be.
*/
@Suppress("DEPRECATION")
@Composable
fun RenderOldBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? OldBookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_bookmark_list),
description = null,
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30003: "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups". */
@Composable
fun RenderLabeledBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? LabeledBookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.titleOrName(), noteEvent.dTag(), R.string.kind_bookmark_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30004: "groups of articles picked by users as interesting". */
@Composable
fun RenderArticleCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? ArticleCurationSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicItems() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_article_curation_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30006: "groups of pictures picked by users as interesting". */
@Composable
fun RenderPictureCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? PictureCurationSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicItems() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_picture_curation_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* The five bookmark-shaped kinds differ only in where their name comes from; what they hold is the
* same `e`/`a` reference, so the body they draw is the same one.
*/
@Composable
private fun BookmarkCard(
title: String,
description: String?,
public: List<BookmarkIdTag>,
private: List<BookmarkIdTag>?,
hidesPrivate: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
ListCard(
title = title,
description = description,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = hidesPrivate,
backgroundColor = backgroundColor,
) { item ->
key(item.toTagIdOnly().joinToString(":")) {
BookmarkMemberRow(item, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
@@ -0,0 +1,57 @@
/*
* 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.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent
/** NIP-51 kind 10102: the relays this user trusts for wiki articles. */
@Composable
fun RenderGoodWikiRelayList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? GoodWikiRelayListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicRelays() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRelays(it) }
ListCard(
title = stringRes(R.string.kind_good_wiki_relays),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { relay ->
key(relay.url) { LabelMemberRow(relay.displayUrl()) }
}
}
@@ -0,0 +1,185 @@
/*
* 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.lists
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.ShowMoreButton
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
/** How many members a list card shows before it asks to be expanded. Matches DisplayPeopleList. */
private const val COLLAPSED_MEMBERS = 3
/**
* The shared shape of every NIP-51 list card: a name, an optional description, the members, and —
* when the viewer is not the list's author — an honest line about the ones they cannot see.
*
* Each list kind supplies its own title, its own member type and its own row, because a mute list
* of pubkeys and a bookmark list of notes are not the same thing and should not pretend to be.
* What they share is this frame.
*/
@Composable
fun <T> ListCard(
title: String,
description: String?,
items: List<T>,
/** True when the event carries private members this viewer has no key for. */
hasUnreadablePrivateItems: Boolean,
backgroundColor: MutableState<Color>,
itemContent: @Composable (T) -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val shown = if (expanded) items else items.take(COLLAPSED_MEMBERS)
Text(
text = title,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
textAlign = TextAlign.Center,
)
description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Only when there is nothing else to show. A card with visible members already reads as a list,
// and most lists keep something private, so saying so on every one of them is noise. The line
// exists for the case that would otherwise be a title and blank space — an all-private list,
// which is what divine.video publishes — so that it reads as closed rather than as broken.
if (hasUnreadablePrivateItems && items.isEmpty()) {
Text(
text = stringRes(R.string.nip51_list_has_private_members),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 2.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
shown.forEach { itemContent(it) }
}
if (items.size > COLLAPSED_MEMBERS && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
ShowMoreButton { expanded = true }
}
}
}
}
/**
* Decrypts a list's private members, and keeps "could not read them" apart from "there are none".
*
* `null` means we never got the plaintext — the viewer is not the author, or the decrypt failed.
* An empty list means we read the content and it holds nothing, which is an empty list and must
* not be labelled private: its author can see straight into it.
*/
@Composable
fun <T> loadPrivateItems(
noteEvent: Event,
accountViewModel: AccountViewModel,
load: suspend (NostrSigner) -> List<T>?,
): State<List<T>?> =
produceState<List<T>?>(initialValue = null, noteEvent) {
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
value =
try {
load(accountViewModel.account.signer)
} catch (e: Exception) {
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
Log.w("NIP51ListCard", "Cannot decrypt private members of ${noteEvent.id}", e)
null
}
}
/**
* True when this event is withholding members from the viewer.
*
* Both halves matter: an empty `content` is a list with no private members at all, and a non-null
* [privateItems] means we already read them, so neither is hidden.
*/
fun Event.hidesPrivateMembers(privateItems: List<*>?): Boolean = content.isNotEmpty() && privateItems == null
/** A list's own name if it has one, else the `d` tag it is addressed by, else the kind's name. */
@Composable
fun listTitle(
explicitTitle: String?,
dTag: String?,
fallback: Int,
): String =
explicitTitle?.takeIf { it.isNotBlank() }
?: dTag?.takeIf { it.isNotBlank() }
?: stringRes(fallback)
/** Remembers the concatenation of a list's public and (once decrypted) private members. */
@Composable
fun <T> rememberAllMembers(
public: List<T>,
private: List<T>?,
): List<T> = remember(public, private) { if (private == null) public else public + private }
@@ -0,0 +1,163 @@
/*
* 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.lists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
/**
* A member that is a person. Loaded lazily and skipped until the profile arrives, because a bare
* hex key in a list of people reads as noise rather than as a member.
*/
@Composable
fun UserMemberRow(
pubKey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadUser(pubKey, accountViewModel) { user ->
user?.let {
Column(modifier = Modifier.fillMaxWidth()) {
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
/**
* A member that is a note or an addressable event — a bookmark, a curated article, a repository.
*
* [quotesLeft] guards the recursion: a bookmarked note can itself quote things, and NoteCompose
* decrements it on the way down.
*/
@Composable
fun BookmarkMemberRow(
item: BookmarkIdTag,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
when (item) {
is AddressBookmark -> AddressMemberRow(item.address, quotesLeft, backgroundColor, accountViewModel, nav)
is EventBookmark ->
LoadNote(item.eventId, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
/** A member named by coordinate: an article, a repository, an app, a DVM feed. */
@Composable
fun AddressMemberRow(
address: Address,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
LoadAddressableNote(address, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
/**
* A member that is just a word: a hashtag, a geohash, a muted word.
*
* [onClick] is optional because only some of these lead anywhere — a hashtag opens its feed, a
* muted word opens nothing.
*/
@Composable
fun LabelMemberRow(
label: String,
onClick: (() -> Unit)? = null,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
/** A hashtag member, which opens its feed. */
@Composable
fun HashtagMemberRow(
hashtag: String,
nav: INav,
) = LabelMemberRow("#$hashtag") { nav.nav(Route.Hashtag(hashtag)) }
@@ -0,0 +1,109 @@
/*
* 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.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
/**
* NIP-51 kind 10000: the mute list.
*
* The only list whose members are of four different kinds at once — people, hashtags, words and
* threads — so it is the one kind that cannot borrow another card's body. Each member is drawn as
* what it actually is: a profile row for a person, a hashtag that opens its feed, a word as
* itself, and a muted thread as the note it silences.
*
* Most mute lists are mostly private, and for anyone but their author this renders as a title and
* an honest line saying so — which is the entire point, given that it used to render as the
* NIP-44 ciphertext.
*/
@Composable
fun RenderMuteList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MuteListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicMutes() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutes(it) }
ListCard(
title = stringRes(R.string.kind_mute_list),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { mute ->
when (mute) {
is UserTag -> key(mute.pubKey) { UserMemberRow(mute.pubKey, accountViewModel, nav) }
is HashtagTag -> key(mute.hashtag) { HashtagMemberRow(mute.hashtag, nav) }
is WordTag -> key(mute.word) { LabelMemberRow(mute.word) }
is EventTag ->
key(mute.eventId) {
MutedThreadRow(mute.eventId, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
}
/** A muted conversation, shown as the note it silences. */
@Composable
private fun MutedThreadRow(
eventId: String,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
LoadNote(eventId, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -0,0 +1,118 @@
/*
* 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.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
/** NIP-51 kind 10015: the hashtags this user follows. */
@Composable
fun RenderHashtagList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? HashtagListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicHashtags() }
// HashtagListEvent exposes no private accessor of its own, but it is a PrivateTagArrayEvent
// like the rest, so its private half is read the same way InterestSetEvent reads its own.
val private by loadPrivateItems(noteEvent, accountViewModel) { signer ->
noteEvent.privateTags(signer)?.mapNotNull(HashtagTag::parse)
}
ListCard(
title = stringRes(R.string.kind_hashtag_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { tag ->
key(tag) { HashtagMemberRow(tag, nav) }
}
}
/** NIP-51 kind 30015: "interest topics represented by a bunch of hashtags". */
@Composable
fun RenderInterestSet(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? InterestSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicHashtags() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateHashtags(it) }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_interest_set),
description = noteEvent.description(),
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { tag ->
key(tag) { HashtagMemberRow(tag, nav) }
}
}
/**
* NIP-51 kind 10081: the places this user follows.
*
* A geohash is not a hashtag — it is a location, and it gets no `#`. Shown verbatim because a
* geohash is what the list stores; turning it into a place name would need a geocoder we don't
* have on this path.
*/
@Composable
fun RenderGeohashList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? GeohashListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicGeohashes() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.decryptPrivateGeohashes(it) }
ListCard(
title = stringRes(R.string.kind_geohash_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { geohash ->
key(geohash) { LabelMemberRow(geohash) }
}
}
@@ -0,0 +1,166 @@
/*
* 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.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent
/** NIP-51 kind 10017: the git authors whose patches this user wants to see. */
@Composable
fun RenderGitAuthorList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GitAuthorListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicAuthors() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
ListCard(
title = stringRes(R.string.kind_git_authors),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { author ->
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
}
}
/** NIP-51 kind 10020: the people whose media this user follows. */
@Composable
fun RenderMediaFollowList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MediaFollowListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicFollows() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFollows(it) }
ListCard(
title = stringRes(R.string.kind_media_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { follow ->
key(follow.pubKey) { UserMemberRow(follow.pubKey, accountViewModel, nav) }
}
}
/** NIP-51 kind 10101: the authors whose wiki articles this user trusts. */
@Composable
fun RenderGoodWikiAuthorList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GoodWikiAuthorListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicAuthors() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
ListCard(
title = stringRes(R.string.kind_good_wiki_authors),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { author ->
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
}
}
/**
* NIP-51 kind 30007: people muted for one kind only.
*
* "`d` tag MUST be the kind string" — so the `d` tag is not a name here, it is the kind being
* muted, and printing it as a title the way other sets do would be wrong.
*/
@Composable
fun RenderKindMuteSet(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? KindMuteSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicMutedUsers() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutedUsers(it) }
val mutedKind = remember(noteEvent) { noteEvent.dTag() }
ListCard(
title = stringRes(R.string.kind_mute_set_for_kind, mutedKind),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { muted ->
key(muted.pubKey) { UserMemberRow(muted.pubKey, accountViewModel, nav) }
}
}
/**
* NIP-51 kind 39092: a media starter pack — "a named set of profiles to be shared around with the
* goal of being followed together". Public by construction: a pack exists to be handed out.
*/
@Composable
fun RenderMediaStarterPack(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MediaStarterPackEvent ?: return
val members = remember(noteEvent) { noteEvent.followIds() }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_media_starter_pack),
description = noteEvent.description(),
items = members,
hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor,
) { pubKey ->
key(pubKey) { UserMemberRow(pubKey, accountViewModel, nav) }
}
}
@@ -2012,6 +2012,12 @@ class AccountViewModel(
fun videoPlayerButtonItemsFlow() = account.settings.syncedSettings.videoPlayer.buttonItems
fun captionsEnabledFlow() = account.settings.syncedSettings.videoPlayer.captionsEnabled
fun setCaptionsEnabled(enabled: Boolean) {
viewModelScope.launch { account.changeCaptionsEnabled(enabled) }
}
fun changeVideoPlayerButtonItems(items: List<com.vitorpamplona.amethyst.model.VideoPlayerButtonItem>) =
launchSigner {
account.changeVideoPlayerButtonItems(items)
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
class UserProfileGalleryFeedFilter(
@@ -53,7 +54,10 @@ class UserProfileGalleryFeedFilter(
val addressableNotes =
LocalCache.addressables
.filter(
listOf(VideoVerticalEvent.KIND, VideoVerticalEvent.KIND),
// Both NIP-71 addressable kinds: acceptableEvent() takes any
// AddressableVideoEvent, so listing 34236 twice silently kept every
// 34235 (horizontal) video out of the gallery.
listOf(VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND),
user.pubkeyHex,
) { _, it ->
acceptableEvent(it, params, user)
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.kinds.KindNames
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -106,6 +107,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -144,6 +146,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
@@ -325,6 +328,9 @@ fun kindDisplayName(kind: Int): Int =
TorrentCommentEvent.KIND -> R.string.kind_torrent_comments
TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays
TrustProviderListEvent.KIND -> R.string.kind_trusted_providers
VideoCurationSetEvent.KIND -> R.string.kind_video_list
VideoCollaborationEvent.KIND -> R.string.kind_video_collaboration
TextTrackEvent.KIND -> R.string.kind_video_subtitles
VideoHorizontalEvent.KIND -> R.string.kind_video_repl
VideoVerticalEvent.KIND -> R.string.kind_shorts_repl
VideoNormalEvent.KIND -> R.string.kind_video
@@ -63,6 +63,8 @@ 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.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions_description
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast_description
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_download
@@ -359,6 +361,7 @@ fun videoPlayerActionName(action: VideoPlayerAction): String =
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download)
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip)
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast)
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions)
}
@Composable
@@ -371,4 +374,5 @@ fun videoPlayerActionDescription(action: VideoPlayerAction): String =
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download_description)
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip_description)
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast_description)
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions_description)
}
+35
View File
@@ -2794,4 +2794,39 @@
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">My Fitness</string>
<string name="captions_off">Off</string>
<string name="captions_unnamed_track">Captions</string>
<string name="captions_turn_on">Turn on captions</string>
<string name="captions_turn_off">Turn off captions</string>
<string name="kind_video_list">Video List</string>
<string name="kind_video_collaboration">Video Collaboration</string>
<string name="kind_video_subtitles">Video Subtitles</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
<string name="video_credit_inspired_by">Inspired by</string>
<string name="video_credit_featuring">Featuring</string>
<string name="video_credit_references">References</string>
<string name="video_credit_audio_from">Audio from</string>
<string name="video_credit_accepted">Credit accepted</string>
<string name="video_collaboration_accepted_on">Accepted a collaboration credit</string>
<string name="video_collaboration_declined_on">Declined a collaboration credit</string>
<string name="video_curation_set_private_items">Private list</string>
<string name="nip51_list_has_private_members">This list has private members only its author can see</string>
<string name="kind_git_authors">Git authors</string>
<string name="kind_media_follows">Media follows</string>
<string name="kind_good_wiki_authors">Trusted wiki authors</string>
<string name="kind_good_wiki_relays">Trusted wiki relays</string>
<string name="kind_mute_set_for_kind">Muted for kind %1$s</string>
<string name="kind_media_starter_pack">Media starter pack</string>
<string name="kind_bookmark_set">Bookmark set</string>
<string name="kind_article_curation_set">Article list</string>
<string name="kind_picture_curation_set">Picture list</string>
<string name="kind_app_curation_set">App list</string>
<string name="kind_git_repositories">Git repositories</string>
<string name="kind_favorite_algo_feeds">Favorite feeds</string>
<string name="kind_interest_set">Interests</string>
<string name="kind_simple_groups">Groups</string>
<plurals name="video_curation_set_video_count">
<item quantity="one">%1$d video</item>
<item quantity="other">%1$d videos</item>
</plurals>
</resources>
@@ -182,6 +182,7 @@ import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.experimental.ratings.RelayReviewEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -299,6 +300,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -355,6 +357,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
@@ -3888,6 +3891,9 @@ open class EventCache :
is TrustProviderListEvent,
is VideoHorizontalEvent,
is VideoVerticalEvent,
is TextTrackEvent,
is VideoCollaborationEvent,
is VideoCurationSetEvent,
is WebBookmarkEvent,
is ExerciseTemplateEvent,
is PublicationIndexEvent,
@@ -0,0 +1,104 @@
/*
* 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.commons.model.nip71Video
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.tags.TextTrackTag
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
/**
* One timed-text track a player can side-load, resolved from a video's `text-track` tag.
*
* [url] is what the player fetches. NIP-71 never says what media type a track is served as, and a
* blossom-hosted one has no file extension either, so [mimeType] is always declared — WebVTT
* unless the referenced [TextTrackEvent] says otherwise.
*/
@Immutable
data class CaptionTrack(
val url: String,
val mimeType: String = TextTrackEvent.DEFAULT_MIME_TYPE,
val language: String? = null,
// NIP-71 calls this the "type of supplementary information": captions, subtitles, chapters or
// metadata. Kept as published so a track picker can label and group by it.
val type: String? = null,
)
/**
* Splits a video's `text-track` tags into the ones a player can load on its own and the ones that
* first need a [TextTrackEvent] fetched.
*
* A `text-track` ref is either a URL (load it) or an addressable coordinate (resolve it). The same
* track is routinely published both ways — divine.video emits the Blossom URL and the
* `39307:<pubkey>:subtitles:<d>` coordinate side by side — so the two lists overlap by design and
* [mergeCaptionTracks] is what de-duplicates them once the events are in hand.
*/
fun VideoEvent.captionTracks(): ResolvedCaptionTracks {
val direct = mutableListOf<CaptionTrack>()
val pending = mutableListOf<PendingCaptionTrack>()
textTrack().forEach { tag ->
val address = Address.parse(tag.ref)
if (address != null) {
pending.add(PendingCaptionTrack(address, tag))
} else if (tag.ref.startsWith("http://", true) || tag.ref.startsWith("https://", true)) {
direct.add(CaptionTrack(url = tag.ref, language = tag.language, type = tag.type))
}
// Anything else — a bare event id, per NIP-71's own example — is not loadable as a file
// and has no address to resolve, so it is dropped rather than handed to the player as a
// URL it would fail to fetch.
}
return ResolvedCaptionTracks(direct, pending)
}
@Immutable
data class ResolvedCaptionTracks(
val direct: List<CaptionTrack>,
val pending: List<PendingCaptionTrack>,
)
@Immutable
data class PendingCaptionTrack(
val address: Address,
val tag: TextTrackTag,
)
/** The track a resolved [TextTrackEvent] contributes, or null when it hosts no fetchable file. */
fun TextTrackEvent.toCaptionTrack(tag: TextTrackTag? = null): CaptionTrack? {
val url = url() ?: return null
return CaptionTrack(
url = url,
mimeType = mimeType() ?: TextTrackEvent.DEFAULT_MIME_TYPE,
// The event is the more specific source: it was written by whoever made the track, while
// the tag on the video is the video author's description of it.
language = language() ?: tag?.language,
type = tag?.type,
)
}
/**
* Collapses tracks that point at the same file. A video that advertises one track twice — once as
* a URL and once as the event that hosts it — would otherwise hand the player two identical
* subtitle configurations, and ExoPlayer shows both as selectable tracks.
*/
fun mergeCaptionTracks(vararg lists: List<CaptionTrack>): List<CaptionTrack> = lists.flatMap { it }.distinctBy { it.url }
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.VideoMeta
import com.vitorpamplona.quartz.nip71Video.tags.HlsImetaTag
/**
* Picks the `imeta` entry a NIP-71 event's player should load.
@@ -58,7 +59,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoMeta
* keeps the placeholder and the layout intact when the master turns out to be the bare one.
*/
fun VideoEvent.selectVideoTrack(): VideoMeta? {
val imetas = imetaTags()
val imetas = imetaTags().flatMap { it.withHlsVariant() }
if (imetas.size <= 1) return imetas.firstOrNull()
val candidates = imetas.filter { it.canBeTheVideo() }
@@ -81,6 +82,20 @@ fun VideoEvent.selectVideoTrack(): VideoMeta? {
return selected.withLadderMetadataFrom(imetas)
}
// A publisher that keeps one imeta per video and hangs its adaptive manifest off the
// non-standard `hls` property (divine.video does; see [HlsImetaTag]) describes a ladder NIP-71
// would have written as a second imeta. Expand it into one, listed first so the HLS preference
// below reaches it, and drop the fields that describe the progressive file specifically: `x` and
// `size` are that blob's digest and byte count, and a manifest is neither.
private fun VideoMeta.withHlsVariant(): List<VideoMeta> {
val manifest = hls?.takeIf { it.isNotBlank() && it != url } ?: return listOf(this)
return listOf(
copy(url = manifest, mimeType = HlsImetaTag.MIME_TYPE, hash = null, size = null, hls = null),
copy(hls = null),
)
}
// A NIP-71 event asserts it is a video, so an imeta is a candidate unless it says otherwise:
// `audio/*` is a separate track under PR #2255, and `image/*` is a poster. Everything else —
// including the HLS playlist MIMEs, which are neither `video/*` nor an image, and a bare URL with
@@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateReplaceableTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -47,6 +49,12 @@ fun Event.hasEncryptedContent(): Boolean =
is SealedRumorEvent -> true
is GiftWrapEvent -> true
is LnZapRequestEvent -> isPrivateZap()
// Every NIP-51 list keeps its private members as a NIP-44 payload in `content`. The two
// base classes cover all ~30 list kinds, present and future, which is the point: naming
// them one at a time is how kind 30005 came to print its own ciphertext on screen.
// A list with no private members carries an empty `content`, and the guard below skips it.
is PrivateTagArrayEvent -> content.isNotEmpty()
is PrivateReplaceableTagArrayEvent -> content.isNotEmpty()
else -> false
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -67,6 +68,10 @@ val PostsAndChatMessagesToAddresses =
listOf(
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
// A collaborator's answer to being credited on a NIP-71 video. Only the `a` tag is
// common to both shapes in the wild — divine-web keys the event by a random `d`, so
// there is no coordinate to ask for and this `a` filter is the only way to see it.
VideoCollaborationEvent.KIND,
)
/**
@@ -28,7 +28,30 @@ import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif")
// The media types the video/shorts feeds will admit. HLS playlists are in here because a
// blossom-hosted manifest is `https://host/<sha256>` with no extension at all, so the MIME is the
// only thing SupportedContent can match on — without them an adaptive-only video is dropped from
// the feed entirely. All four spellings appear in the wild; RichTextParser.isHlsMimeType and
// MediaItemCache.toExoPlayerMimeType recognise the same set.
val SUPPORTED_VIDEO_FEED_MIME_TYPES =
listOf(
"image/jpeg",
"image/gif",
"image/png",
"image/webp",
"image/avif",
"video/mp4",
"video/mpeg",
"video/webm",
"audio/aac",
"audio/mpeg",
"audio/webm",
"audio/wav",
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/x-mpegurl",
"audio/mpegurl",
)
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
val PictureAndVideoKinds =
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
// URL-based media content models (KMP). The locally-cached variants
// (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) live in
@@ -115,6 +118,8 @@ open class MediaUrlVideo(
thumbhash: String? = null,
val isLiveStream: Boolean = false,
authorPubKey: String? = null,
// NIP-71 `text-track` captions resolved for this video, for the player to side-load.
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey)
@Immutable
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -176,6 +177,10 @@ object RenderableKinds {
VideoShortEvent.KIND,
VideoHorizontalEvent.KIND,
VideoVerticalEvent.KIND,
// A video list has a card of its own, so by the rule the omission list states — in
// ALL when Amethyst can render it — it belongs in the window. Its title and
// description are what Quartz indexes, which is exactly what a reader would search.
VideoCurationSetEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
MusicTrackEvent.KIND,
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.model.privateChats
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -69,6 +71,50 @@ class ChatPreviewTest {
assertFalse(nip17Rumor().hasEncryptedContent())
}
// A NIP-51 list keeps its private members as a NIP-44 payload in `content`. This is the real
// wire content of a divine.video kind-30005 video list, which used to render on screen as the
// base64 blob it is.
private val nip44ListContent =
"AqQxhO1d0DzuJQtkWdshs3Fvi/1OXp2sYl9rDAykMBOvD06Tg0DLP69/i03iA1SD/RHVp+TEUoG8k+0FUkr7fiEcSyWx9k37o5URbcGsESdMK9d5vAFm9Yjp5tatUuZa2gJG"
private fun videoList(content: String = nip44ListContent) =
VideoCurationSetEvent(
id = "vl".padEnd(64, '0'),
pubKey = other,
createdAt = 1_000,
tags = arrayOf(arrayOf("d", "my_vine_list"), arrayOf("title", "My List")),
content = content,
sig = someSig,
)
private fun muteList(content: String = nip44ListContent) =
MuteListEvent(
id = "ml".padEnd(64, '0'),
pubKey = other,
createdAt = 1_000,
tags = arrayOf(arrayOf("p", stranger)),
content = content,
sig = someSig,
)
@Test
fun anAddressableListWithPrivateMembersCarriesCiphertext() {
assertTrue(videoList().hasEncryptedContent())
}
@Test
fun aStandardListWithPrivateMembersCarriesCiphertext() {
assertTrue(muteList().hasEncryptedContent())
}
@Test
fun anAllPublicListHasNothingToHide() {
// No private members means no payload, and an empty string is not ciphertext — saying it
// was would hide a perfectly readable list behind a "private" label.
assertFalse(videoList(content = "").hasEncryptedContent())
assertFalse(muteList(content = "").hasEncryptedContent())
}
// ---- chatPreviewOf -------------------------------------------------------
@Test
@@ -147,7 +147,7 @@ class KindRegistryTest {
assertEquals(
// 7516 is a found log: read inside a cache's thread, where `kind:geocache` already
// leads, rather than looked up on its own.
listOf(24, 54, 1018, 1111, 1337, 1808, 7516, 10001, 10003, 30000, 30001, 30053, 30296, 30297, 30817, 31337),
listOf(24, 54, 1018, 1111, 1337, 1808, 7516, 10001, 10003, 30000, 30001, 30005, 30053, 30296, 30297, 30817, 31337),
RenderableKinds.ALL.filter { KindRegistry.nameFor(it) == null }.sorted(),
)
}
@@ -87,6 +87,10 @@ class RenderableKindsTest {
1163,
32176,
39092,
// subtitle tracks: their cue text is indexed so a store can find what was said in
// a video, but the track has no card of its own — a hit here would be a wall of
// WebVTT rather than the video it belongs to.
39307,
// git: the repo itself is searchable, its patch/issue/status traffic is not
818,
1617,
@@ -142,7 +146,6 @@ class RenderableKindsTest {
30002,
30003,
30004,
30005,
30006,
30015,
30267,
@@ -0,0 +1,375 @@
/*
* 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.commons.model.nip71Video
import com.vitorpamplona.amethyst.commons.relayClient.video.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.credits.CreditTarget
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Interop guard for divine.video (<https://github.com/divinevideo>), the biggest publisher of
* NIP-71 kind-34236 short videos on the network. Every fixture below is a real event pulled from
* `wss://relay.divine.video` — only the oversized `proofmode`/`device_attestation`/
* `identity_binding` blobs were stripped, which is why the signatures are not re-verified here.
*
* Divine's events exercise two shapes Amethyst used to get wrong and can silently regress on:
*
* 1. **Extension-less media URLs.** Videos live on Blossom, so the URL is
* `https://media.divine.video/<sha256>` with no `.mp4`. Only the `imeta` `m` field says it is
* a video, so every extension-driven branch (feed admission, image-vs-player classification)
* has to consult the declared MIME first.
* 2. **Dual pointers on every interaction.** Reactions, reposts and comments carry BOTH the
* addressable coordinate (`a`/`A`) and the specific version's event id (`e`/`E`), so both
* have to resolve or the counters land on a note nobody renders.
*/
class DivineVideoInteropTest {
private val video =
"""{"id":"fa5a793b24edfe109f8d02ad6aa6cde77b0a923566f891df403049721b46e8d9","pubkey":"4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb","created_at":1789661586,"kind":34236,"tags":[["d","c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862"],["text-track","https://media.divine.video/283a420202b620a9a326598b85bfcf0e8cb4ab4d52b947c628d8c29a1313006b","wss://relay.divine.video","captions","en"],["text-track","39307:4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb:subtitles:c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862","wss://relay.divine.video","captions","en"],["imeta","url https://media.divine.video/c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862","m video/mp4","image https://media.divine.video/e1d22b85609cb105dff64b4a402f13982a942dc59aee67218e6e3652c8597d2d","dim 1080x1920","x c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862","size 6579464","blurhash TQHxc{D%X7_Nw{nio#RiRjaJX8oz"],["title","Xmas Already??"],["summary","In the words of Jack Skellington from The Nightmare Before Christmas, \"What's this?\" 😭"],["t","retail"],["published_at","1789661611"],["duration","6"],["alt","Xmas Already??"],["allow_audio_reuse","true"],["e","218af5fc90d66a16ce273f00a4e412a71443441c04c67d1e34ff99c654871d3d","wss://relay.divine.video","audio"],["c2pa_manifest_id","urn:c2pa:b1d12836-a60f-4398-a081-c8f047483ab4"],["verification","verified_mobile"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"In the words of Jack Skellington from The Nightmare Before Christmas, \"What's this?\" 😭","sig":"c32a6486480465d3c4c695d8eee52868eb9c5e085fe54827478f7e183fea0602e969fa82476053136ae766536ec9ed7f0d4c9d009c5fb6991a2099696c66fc33"}"""
private val videoWithCredits =
"""{"id":"085e909ec9acecdc0833b56162d358f057539bd56a12dabb3c4a76eba13ab176","pubkey":"62e04946ab28d27866f9c29a2c1b88e5ddca31a87110208249fd88693bc406f1","created_at":1789666628,"kind":34236,"tags":[["d","1de7ec53cfc7db7f4e0490aa76ac26408bc9052d287cd5565b96881085929972"],["imeta","url https://media.divine.video/1de7ec53cfc7db7f4e0490aa76ac26408bc9052d287cd5565b96881085929972","m video/mp4"],["title","ShowHole"],["a","34236:0db19e8f1b13e03cd07379edd37a71f07b08a4fb5e7eac222ba1d2c823807075:f11e7f9e2f0e11e80056893152f1850897d4f9164515e567ce13e443b953e305","wss://relay.divine.video","mention"],["p","0db19e8f1b13e03cd07379edd37a71f07b08a4fb5e7eac222ba1d2c823807075","wss://relay.divine.video","inspired-by"],["e","6a8edc14380fdd347ae9c8861b008e4771b885bfb55f1899979bd7a9515b3221","wss://relay.divine.video","audio"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"","sig":""}"""
private val subtitleEvent =
"""{"id":"5a1c96f3f24b18ee1a9dbb5be04b60e4d7bb8f5e2dd1f2a9e9cf9f5f14b0e1f1","pubkey":"4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb","created_at":1789661600,"kind":39307,"tags":[["d","subtitles:c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862"],["a","34236:4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb:c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862"],["url","https://media.divine.video/283a420202b620a9a326598b85bfcf0e8cb4ab4d52b947c628d8c29a1313006b"],["m","text/vtt"],["l","en"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"WEBVTT\n\n1\n00:00:00.000 --> 00:00:02.500\nWe just started putting out Christmas stuff\n\n","sig":""}"""
private val repost =
"""{"id":"20a0663382290c769b1fbd5e6ad84593d1f848fa75689e4ee4d6263aad5c8760","pubkey":"f67d985c0bfbf87eaa33b056f1d38ad991a6aa625b138bddb2a838bdbac29f40","created_at":1789666347,"kind":16,"tags":[["k","34236"],["a","34236:5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb:8de0dcf06982b86aca7189ae50a8c3fe8605917433044ad62c146b723b877025"],["p","5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb"],["e","ab074d5a577635b9d34281b31ea32170d54b09cfb4cbfcc4ae6a28f52653fca1"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"","sig":"4513526b886a1d9e1fb12a181a48811dc8765afd901458959a674d3e9d234b761a5cd2551c75cbe4210543f30c5a74b16720281acf88e11e09a67b0b219e3d3d"}"""
private val reaction =
"""{"id":"88847c819aa18d9294de9ebc1156d6200becdd2fadad6bc6a0248b24f22d3c42","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":7,"tags":[["e","a75d3c1a2fb824544ae51d3d20b1a8280aea9647d13f87c06418233883e80890"],["a","34236:03c49dd3d68fdd15fc0bc7dff669d652af313408bfd6dde10daf27b02f54eb50:a983212b6a82e0d6f6a41efc085ddd0176e1cd3ebd8dcb4cac16219db0a283ef"],["p","03c49dd3d68fdd15fc0bc7dff669d652af313408bfd6dde10daf27b02f54eb50"],["k","34236"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"+","sig":"99d515ef57cb8425e26b85051202e305fb6f1f4265ea38bb041da24044c8ac3e95339bdcae01400307afa26417fb056b6aad1a34e826c4e267df7b4dc6c29e94"}"""
private val comment =
"""{"id":"dac3c50f5a3c22f185dc8587a2eae3357087aa0ccfd1f1230f8308d7a047515a","pubkey":"f67d985c0bfbf87eaa33b056f1d38ad991a6aa625b138bddb2a838bdbac29f40","created_at":1789666353,"kind":1111,"tags":[["E","ab074d5a577635b9d34281b31ea32170d54b09cfb4cbfcc4ae6a28f52653fca1","","5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb"],["A","34236:5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb:8de0dcf06982b86aca7189ae50a8c3fe8605917433044ad62c146b723b877025",""],["K","34236"],["P","5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb"],["e","ab074d5a577635b9d34281b31ea32170d54b09cfb4cbfcc4ae6a28f52653fca1","","5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb"],["a","34236:5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb:8de0dcf06982b86aca7189ae50a8c3fe8605917433044ad62c146b723b877025",""],["k","34236"],["p","5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"⚫️","sig":"8fa4fcb27882b0622d48e21f1323e4b16c2df24f6a127c49bc30a6e277ef6069f5cfb7b13ddf9788ac18c4cde031091b1d39e349cedde812a6fc8fd517dc3d1f"}"""
private val videoList =
"""{"id":"70118cbfdd6ed7a0b774d083eb7e788a04f39660ad1aecb01cc645902d06cb9f","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666571,"kind":30005,"tags":[["d","my_vine_list"],["title","My List"],["description","My favorite vines and videos"],["playorder","chronological"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"Ai9Jcq6da7mTNMTcnDh+I4ZUwQcX5///rkV6ouuE7ZqaBnRIgg+rCqul8ykw4KKj6O7wwTm3jzxmTD7ZeEjD7LplcaH8kYDPnzAJWriiLC6tJ1THmXwx0SYYNYrtLs+t1cBR","sig":"7db79697f4adf9ff8b6aba918e5760508fe96fd8227b600ba41edc84d4efbf5a37af15ed7980da5e6106b9a7241583e3e4dce473728f215a0d70b0b28dbf52b2"}"""
private val collabResponse =
"""{"id":"70cd826d52989fbfd6a12f7e62e4f9aa3c01c902c1bdeec61955d0271920a248","pubkey":"ddfdea0a598ec89f1383ca83b71993540c99ea5a3734b4d5aab23719ea6bde80","created_at":1789653321,"kind":34238,"tags":[["d","34236:32d84a21bb7702c538d5ddd3f9086e86e8b73e5d5cb2e67861eaca36708597e6:d67650d7e97d50a35f491ef24f463b4b408026b5b08c0bc9dbc8359cf4b8aa05"],["a","34236:32d84a21bb7702c538d5ddd3f9086e86e8b73e5d5cb2e67861eaca36708597e6:d67650d7e97d50a35f491ef24f463b4b408026b5b08c0bc9dbc8359cf4b8aa05","wss://relay.divine.video","root"],["p","32d84a21bb7702c538d5ddd3f9086e86e8b73e5d5cb2e67861eaca36708597e6"],["role","Collaborator"],["status","accepted"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"","sig":"65e476c6023de6e443810e630450e6f2c3bca0201a970adcb89f18788dc9bf2ab8e5431a68ff716d411aaa05caa83288549efed8814750c2a23cec62c69bab18"}"""
@Test
fun aShortVideoIsAnAddressableVerticalVideo() {
val event = Event.fromJson(video)
assertTrue(event is VideoVerticalEvent)
// The address must come from the `d` tag: an `a` tag elsewhere on the network points at
// `34236:<pubkey>:<d>`, and a class on the wrong base would split the cache in two.
assertEquals(
"34236:4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb:c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862",
(event as AddressableEvent).address().toValue(),
)
assertEquals("Xmas Already??", (event as VideoVerticalEvent).title())
assertEquals(6, event.duration())
}
@Test
fun theBlossomUrlIsPlayedAsAVideoDespiteHavingNoFileExtension() {
val track = (Event.fromJson(video) as VideoVerticalEvent).selectVideoTrack()
assertNotNull(track)
assertEquals("https://media.divine.video/c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862", track!!.url)
assertEquals("video/mp4", track.mimeType)
assertEquals("1080x1920", track.dimension.toString())
assertEquals(
"https://media.divine.video/e1d22b85609cb105dff64b4a402f13982a942dc59aee67218e6e3652c8597d2d",
track.image.firstOrNull(),
)
// VideoDisplay diverts to the image viewer only when classifyMedia says IMAGE. The URL has
// no extension at all, so the declared MIME is the only thing keeping this in the player.
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia(track.url, track.mimeType))
// ...and the same MIME is what admits it into the Shorts/Video feeds, whose
// SupportedContent matcher would otherwise fall through to the extension list.
assertTrue(track.mimeType in SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)
}
@Test
fun theAudioSourceETagIsNotAReplyPointer() {
// Divine marks the reused soundtrack as `["e", <id>, <relay>, "audio"]` on the video
// itself. LocalCache.computeReplyTo has no branch for video events, so this never turns
// the post into a reply — which would drop it out of the home feed and thread it under
// whatever video the audio came from.
val event = Event.fromJson(video) as VideoVerticalEvent
val audio = event.tags.first { it[0] == "e" }
assertEquals("218af5fc90d66a16ce273f00a4e412a71443441c04c67d1e34ff99c654871d3d", audio[1])
assertEquals("audio", audio[3])
}
@Test
fun captionsComeFromTheTextTrackTagsAndNotTheAudioPointer() {
// textTrack() used to run ETag::parse, so it returned the `e` tags instead — on this
// event, the reused-soundtrack pointer, which is not a caption track at all.
val tracks = (Event.fromJson(video) as VideoVerticalEvent).textTrack()
assertEquals(2, tracks.size)
// Divine publishes the same track twice: the WebVTT file on Blossom, and the addressable
// kind-39307 subtitle event that wraps it.
assertEquals("https://media.divine.video/283a420202b620a9a326598b85bfcf0e8cb4ab4d52b947c628d8c29a1313006b", tracks[0].ref)
assertEquals("39307:4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb:subtitles:c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862", tracks[1].ref)
tracks.forEach {
assertEquals("wss://relay.divine.video", it.relay)
assertEquals("captions", it.type)
assertEquals("en", it.language)
}
// No renderer consumes these yet — Amethyst plays divine.video shorts without captions.
}
@Test
fun theTwoFormsOfTheSameCaptionTrackCollapseIntoOne() {
val video = Event.fromJson(video) as VideoVerticalEvent
val refs = video.captionTracks()
// One `text-track` is directly loadable, the other has to be fetched first.
assertEquals(
listOf("https://media.divine.video/283a420202b620a9a326598b85bfcf0e8cb4ab4d52b947c628d8c29a1313006b"),
refs.direct.map { it.url },
)
assertEquals(1, refs.pending.size)
assertEquals(
TextTrackEvent.KIND,
refs.pending
.first()
.address.kind,
)
assertEquals(
"en",
refs.pending
.first()
.tag.language,
)
// The pending one resolves to the very same file, so the player gets one configuration,
// not two identical entries in its track menu.
val resolved = (Event.fromJson(subtitleEvent) as TextTrackEvent).toCaptionTrack(refs.pending.first().tag)
assertEquals("text/vtt", resolved?.mimeType)
assertEquals("en", resolved?.language)
assertEquals(1, mergeCaptionTracks(refs.direct, listOfNotNull(resolved)).size)
}
@Test
fun aRepostResolvesBothTheCoordinateAndTheVersionId() {
val event = Event.fromJson(repost)
assertTrue(event is GenericRepostEvent)
val boost = event as GenericRepostEvent
assertEquals(34236, boost.boostedKind())
assertEquals(
"34236:5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb:8de0dcf06982b86aca7189ae50a8c3fe8605917433044ad62c146b723b877025",
boost.boostedAddress()?.toValue(),
)
assertEquals("ab074d5a577635b9d34281b31ea32170d54b09cfb4cbfcc4ae6a28f52653fca1", boost.boostedEventId())
}
@Test
fun aReactionResolvesBothTheCoordinateAndTheVersionId() {
val event = Event.fromJson(reaction)
assertTrue(event is ReactionEvent)
val like = event as ReactionEvent
assertEquals("+", like.content)
assertEquals(listOf("a75d3c1a2fb824544ae51d3d20b1a8280aea9647d13f87c06418233883e80890"), like.originalPost())
assertEquals(
listOf("34236:03c49dd3d68fdd15fc0bc7dff669d652af313408bfd6dde10daf27b02f54eb50:a983212b6a82e0d6f6a41efc085ddd0176e1cd3ebd8dcb4cac16219db0a283ef"),
like.linkedAddressIds(),
)
}
@Test
fun aTopLevelCommentRootsAtTheVideoCoordinate() {
val event = Event.fromJson(comment)
assertTrue(event is CommentEvent)
val reply = event as CommentEvent
val coordinate = "34236:5ab67f7d7fed4f781008c0ec0d26c8113f9fb46094a8346246c70c75e75db9fb:8de0dcf06982b86aca7189ae50a8c3fe8605917433044ad62c146b723b877025"
assertEquals(listOf(coordinate), reply.rootAddressIds())
assertEquals(listOf("ab074d5a577635b9d34281b31ea32170d54b09cfb4cbfcc4ae6a28f52653fca1"), reply.rootEventIds())
// A top-level comment repeats the root as its direct parent (lowercase a/e), so both the
// version note and the addressable note collect the reply. Note.addReply() dedupes, and
// consumeBaseReplaceable migrates the version's references onto the address.
assertEquals(listOf(coordinate), reply.replyAddressIds())
}
@Test
fun creditsReadTheMarkerWhicheverSlotItIsIn() {
// divine-mobile: ["p", <pubkey>, <relay>, "inspired-by"] and ["e", <id>, <relay>, "audio"].
val credits = (Event.fromJson(videoWithCredits) as VideoVerticalEvent).credits()
val person = credits.filterIsInstance<VideoCredit>().first { it.target is CreditTarget.Person }
assertEquals("0db19e8f1b13e03cd07379edd37a71f07b08a4fb5e7eac222ba1d2c823807075", (person.target as CreditTarget.Person).pubKey)
assertEquals("inspired-by", person.label)
val video = credits.first { it.target is CreditTarget.Video }
assertEquals("mention", video.label)
assertEquals(34236, (video.target as CreditTarget.Video).address.kind)
val audio = credits.first { it.target is CreditTarget.Event }
assertEquals("audio", audio.label)
assertEquals("6a8edc14380fdd347ae9c8861b008e4771b885bfb55f1899979bd7a9515b3221", (audio.target as CreditTarget.Event).eventId)
}
@Test
fun aRoleInTheRelaySlotIsReadAsTheLabelAndNotAsAHint() {
// divine-web's collaborator invite writes ["p", <pubkey>, "<role>"] — no relay hint at all,
// so the slot PTag reads for a hint holds the label instead.
val invite =
VideoVerticalEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1789666628,
tags = arrayOf(arrayOf("d", "vid"), arrayOf("p", "c".repeat(64), "Collaborator")),
content = "",
sig = "",
)
val credit = invite.credits().single()
assertEquals("Collaborator", credit.label)
assertEquals("c".repeat(64), (credit.target as CreditTarget.Person).pubKey)
}
@Test
fun aBareETagOnAVideoIsNotACredit() {
// No marker means nothing is being credited — printing "references <id>" for a stray
// threading tag would invent an attribution the publisher never made.
val stray =
VideoVerticalEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1789666628,
tags = arrayOf(arrayOf("d", "vid"), arrayOf("e", "d".repeat(64)), arrayOf("e", "e".repeat(64), "", "root")),
content = "",
sig = "",
)
assertTrue(stray.credits().isEmpty())
}
@Test
fun aCollaborationResponseNamesTheVideoItAccepts() {
val event = Event.fromJson(collabResponse)
assertTrue(event is VideoCollaborationEvent)
val response = event as VideoCollaborationEvent
assertEquals(
"34236:32d84a21bb7702c538d5ddd3f9086e86e8b73e5d5cb2e67861eaca36708597e6:d67650d7e97d50a35f491ef24f463b4b408026b5b08c0bc9dbc8359cf4b8aa05",
response.video()?.toValue(),
)
assertEquals("Collaborator", response.role())
assertTrue(response.isAccepted())
// divine-mobile happens to key this one by the video coordinate, but the `a` tag is what
// the lookup matches on — see [bothCollaborationShapesAreFoundByTheirATag].
assertEquals(response.video()?.toValue(), response.dTag())
}
@Test
fun bothCollaborationShapesAreFoundByTheirATag() {
// The `d` tag is the one thing the two publishers disagree on: divine-mobile writes the
// video coordinate, divine-web writes `crypto.randomUUID()`
// (divine-web/src/hooks/useApproveCollab.ts). Matching on `a` + author is what makes the
// credit's check mark appear for both — and is the same filter divine-web itself uses in
// useVideoCollaboratorStatus.ts.
val mobile = Event.fromJson(collabResponse) as VideoCollaborationEvent
val coord = mobile.video()!!.toValue()
val web =
VideoCollaborationEvent(
id = "a".repeat(64),
pubKey = mobile.pubKey,
createdAt = mobile.createdAt,
tags = arrayOf(arrayOf("a", coord), arrayOf("d", "0e1cbb2c-1b5a-4f1f-9f0e-2c6b2c8a9f11")),
content = "",
sig = "",
)
val byATag =
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(mobile.pubKey),
tags = mapOf("a" to listOf(coord)),
)
assertTrue(byATag.match(mobile))
assertTrue(byATag.match(web))
// What the coordinate-addressed lookup used to do: it can only ever name one `d`, so the
// other publisher's answer was invisible however long you waited for it.
val byCoordinate =
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(mobile.pubKey),
tags = mapOf("d" to listOf(coord)),
)
assertTrue(byCoordinate.match(mobile))
assertFalse(byCoordinate.match(web))
}
@Test
fun aResponseWithoutAStatusStillCounts() {
// divine-web publishes no `status` — it only emits the event on approval at all.
val webShape =
VideoCollaborationEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1789666628,
tags = arrayOf(arrayOf("a", "34236:${"c".repeat(64)}:vid"), arrayOf("d", "0e1cbb2c-1b5a-4f1f-9f0e-2c6b2c8a9f11")),
content = "",
sig = "",
)
assertTrue(webShape.isAccepted())
assertEquals("34236:${"c".repeat(64)}:vid", webShape.video()?.toValue())
}
@Test
fun aVideoListParsesButCarriesItsItemsEncrypted() {
val event = Event.fromJson(videoList)
assertTrue(event is VideoCurationSetEvent)
val list = event as VideoCurationSetEvent
assertEquals("my_vine_list", list.dTag())
assertEquals("My List", list.title())
assertEquals("My favorite vines and videos", list.description())
// Divine keeps every member in the NIP-51 encrypted `content`, so nothing is public and
// only the list's owner can decrypt the members; RenderVideoCurationSet says so rather
// than rendering an empty strip as though the list were broken.
assertTrue(list.publicItems().isEmpty())
}
}
@@ -56,6 +56,42 @@ class VideoTrackSelectionTest {
dimension = DimensionTag(width, height),
)
@Test
fun anHlsPropertyOnTheImetaBecomesTheSelectedTrack() {
// divine.video's shape: one imeta for the progressive file, with the adaptive manifest
// hung off the non-standard `hls` key instead of a second imeta.
val progressive =
VideoMeta(
url = "https://media.divine.video/abc",
mimeType = "video/mp4",
dimension = DimensionTag(1080, 1920),
hash = "abc",
size = 6579464,
hls = "https://media.divine.video/abc/hls/master.m3u8",
)
val selected = event(progressive).selectVideoTrack()
assertEquals("https://media.divine.video/abc/hls/master.m3u8", selected?.url)
assertEquals(hls, selected?.mimeType)
// Shape metadata is the same footage, so it carries over...
assertEquals("1080x1920", selected?.dimension.toString())
// ...but `x` and `size` describe the mp4 blob, and a manifest is not that blob.
assertNull(selected?.hash)
assertNull(selected?.size)
}
@Test
fun aRedundantHlsPropertyDoesNotDuplicateTheTrack() {
val selfReferential =
VideoMeta(
url = "https://host/master.m3u8",
mimeType = hls,
hls = "https://host/master.m3u8",
)
assertEquals("https://host/master.m3u8", event(selfReferential).selectVideoTrack()?.url)
}
@Test
fun picksTheMasterWhenItIsListedFirst() {
// Amethyst's own layout (HlsVideoEventBuilder): master first, dim = the top rung.
@@ -2213,6 +2213,8 @@
<string name="video_player_settings_action_pip_description">Play the video in a floating window (hidden if unsupported)</string>
<string name="video_player_settings_action_cast">Cast to Device</string>
<string name="video_player_settings_action_cast_description">Send the video to a Chromecast receiver on your Wi-Fi (hidden for local files)</string>
<string name="video_player_settings_action_captions">Captions</string>
<string name="video_player_settings_action_captions_description">Turn subtitles on or off (hidden when the video has none)</string>
<string name="audio_visualizer_settings_description">Choose the animation shown while audio notes play.</string>
<string name="audio_visualizer_off">Off</string>
<string name="audio_visualizer_bars">Spectrum Bars</string>
@@ -67,6 +67,8 @@ object MaterialSymbols {
val ChessKnight = MaterialSymbol("\uF25E")
val Checklist = MaterialSymbol("\uE6B1")
val ChevronRight = MaterialSymbol("\uE5CC")
val ClosedCaption = MaterialSymbol("\uE996")
val ClosedCaptionDisabled = MaterialSymbol("\uF1DC")
val Circle = MaterialSymbol("\uEF4A")
val Clear = MaterialSymbol("\uE5CD")
val Close = MaterialSymbol("\uE5CD")
@@ -104,6 +104,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -1417,7 +1418,16 @@ suspend fun voteOnPoll(
) {
if (responses.isEmpty()) return
withContext(Dispatchers.IO) {
val template = PollResponseEvent.build(EventHintBundle(poll), responses)
// The poll decides how its answers are counted, so it decides which builder applies:
// a single-choice answer is the first tag only, and sending several would record
// whichever one the reader saw first.
val hint = EventHintBundle(poll)
val template =
when (poll.pollType()) {
PollType.SINGLE_CHOICE ->
responses.firstOrNull()?.let { PollResponseEvent.buildSingleChoice(hint, it) } ?: return@withContext
PollType.MULTI_CHOICE -> PollResponseEvent.buildMultipleChoice(hint, responses)
}
val signed = account.signer.sign(template)
localCache.consume(signed, null, wasVerified = true)
// Publish to the poll's OWN declared relays (NIP-88 `relay` tags) as well as our
@@ -0,0 +1,112 @@
/*
* 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.experimental.videoCollaboration
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.videoCollaboration.tags.RoleTag
import com.vitorpamplona.quartz.experimental.videoCollaboration.tags.StatusTag
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A collaborator's answer to being credited on someone else's NIP-71 video.
*
* The invite is not an event of its own: the video's author adds the collaborator as a `p` tag on
* the video itself, and the collaborator answers with one of these, `a`-tagging the video. Only
* the collaborator can sign it, which is the point — it is what turns an unsolicited tag into a
* credit the tagged person actually stands behind.
*
* Two shapes exist in the wild and both are accepted here, because the `a` tag is the only field
* either guarantees:
* - divine-web writes a random `d` with the coordinate in `a`;
* - divine-mobile writes the coordinate itself as `d` (one response per video, replaceable) and
* adds `p`, `role` and `status`.
*
* A response with no `status` is an acceptance: divine-web only publishes the event when the
* collaborator approves, so the event's existence *is* the answer. [isAccepted] reads it that way.
*/
@Immutable
class VideoCollaborationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
/** The video being collaborated on. */
fun video() = tags.firstNotNullOfOrNull(ATag::parseAddress)
/** The video's author, when the response names them. */
fun videoAuthor() = tags.firstNotNullOfOrNull(PTag::parseKey)
/** Free text, as the video's author typed it: "Collaborator", "Director", … */
fun role() = tags.firstNotNullOfOrNull(RoleTag::parse)
fun status() = tags.firstNotNullOfOrNull(StatusTag::parse)
/**
* True unless the collaborator explicitly said no. An absent `status` means yes — see the
* class docs: one of the two publishers only emits the event on approval.
*/
fun isAccepted() = status()?.let { it == StatusTag.ACCEPTED } ?: true
companion object {
const val KIND = 34238
fun build(
video: ATag,
videoAuthor: HexKey? = null,
role: String? = null,
status: String = StatusTag.ACCEPTED,
// One response per video: keying on the coordinate makes a later answer replace the
// earlier one instead of leaving both on relays for readers to date-sort.
dTag: String = video.toTag(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<VideoCollaborationEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(dTag)
add(video.toATagArray())
videoAuthor?.let { add(PTag.assemble(it, null)) }
role?.let { add(RoleTag.assemble(it)) }
add(StatusTag.assemble(status))
initializer()
}
}
}
@@ -0,0 +1,40 @@
/*
* 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.experimental.videoCollaboration.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** What the collaborator is credited as, as free text the video's author typed. */
class RoleTag {
companion object {
const val TAG_NAME = "role"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(role: String) = arrayOf(TAG_NAME, role)
}
}
@@ -0,0 +1,43 @@
/*
* 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.experimental.videoCollaboration.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Whether the collaborator took the credit. Absent means accepted — see VideoCollaborationEvent. */
class StatusTag {
companion object {
const val TAG_NAME = "status"
const val ACCEPTED = "accepted"
const val DECLINED = "declined"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(status: String) = arrayOf(TAG_NAME, status)
}
}
@@ -192,6 +192,7 @@ object SearchableKinds {
39000, // GroupMetadataEvent
39089, // FollowListEvent
39092, // MediaStarterPackEvent
39307, // TextTrackEvent
39701, // WebBookmarkEvent
40002, // StreamMessageV2Event
40100, // CanvasEvent
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nip71Video
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
@@ -32,8 +31,10 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredits
import com.vitorpamplona.quartz.nip71Video.tags.DurationTag
import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag
import com.vitorpamplona.quartz.nip71Video.tags.TextTrackTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
@@ -82,7 +83,11 @@ abstract class AddressableVideoEvent(
override fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse)
override fun textTrack() = tags.mapNotNull(ETag::parse)
// `text-track`, not `e`: reading ETag here returned the event's unrelated `e` tags
// (on a divine.video short, its "audio" source pointer) and never a caption track.
override fun textTrack() = tags.mapNotNull(TextTrackTag::parse)
override fun credits() = VideoCredits.parse(tags)
override fun segments() = tags.mapNotNull(SegmentTag::parse)
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag
import com.vitorpamplona.quartz.nip71Video.tags.DurationTag
import com.vitorpamplona.quartz.nip71Video.tags.HlsImetaTag
import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
@@ -78,6 +79,8 @@ fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallb
fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service)
fun IMetaTagBuilder.hls(manifestUrl: String) = add(HlsImetaTag.TAG_NAME, manifestUrl)
fun IMetaTagBuilder.bitrate(bitsPerSecond: Int) = add(BitrateTag.TAG_NAME, bitsPerSecond.toString())
fun IMetaTagBuilder.duration(seconds: Double) = add(DurationTag.TAG_NAME, seconds.toString())
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag
import com.vitorpamplona.quartz.nip71Video.tags.DurationTag
import com.vitorpamplona.quartz.nip71Video.tags.HlsImetaTag
import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
@@ -77,6 +78,8 @@ fun IMetaTag.fallback() = properties.get(FallbackTag.TAG_NAME)
fun IMetaTag.service() = properties.get(ServiceTag.TAG_NAME)
fun IMetaTag.hls() = properties.get(HlsImetaTag.TAG_NAME)
fun IMetaTag.bitrate(): Int? = properties.get(BitrateTag.TAG_NAME)?.firstOrNull()?.toIntOrNull()
fun IMetaTag.duration(): Double? = properties.get(DurationTag.TAG_NAME)?.firstOrNull()?.toDoubleOrNull()
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nip71Video
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
@@ -32,8 +31,10 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredits
import com.vitorpamplona.quartz.nip71Video.tags.DurationTag
import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag
import com.vitorpamplona.quartz.nip71Video.tags.TextTrackTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
@@ -71,7 +72,11 @@ abstract class RegularVideoEvent(
override fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse)
override fun textTrack() = tags.mapNotNull(ETag::parse)
// `text-track`, not `e`: reading ETag here returned the event's unrelated `e` tags
// (on a divine.video short, its "audio" source pointer) and never a caption track.
override fun textTrack() = tags.mapNotNull(TextTrackTag::parse)
override fun credits() = VideoCredits.parse(tags)
override fun segments() = tags.mapNotNull(SegmentTag::parse)
@@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.nip71Video
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.IEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit
import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag
import com.vitorpamplona.quartz.nip71Video.tags.TextTrackTag
@Immutable
interface VideoEvent : IEvent {
@@ -34,12 +35,18 @@ interface VideoEvent : IEvent {
fun duration(): Int?
fun textTrack(): List<ETag>
fun textTrack(): List<TextTrackTag>
fun segments(): List<SegmentTag>
fun participants(): List<PTag>
/**
* Everyone and everything this video credits, with the word the publisher used for each.
* See [com.vitorpamplona.quartz.nip71Video.credits.VideoCredits].
*/
fun credits(): List<VideoCredit>
fun hashtags(): List<String>
fun mimeType(): String?
@@ -34,6 +34,9 @@ data class VideoMeta(
val hash: String? = null,
val size: Int? = null,
val service: String? = null,
// Non-standard `hls` property (divine.video): the adaptive manifest for the same
// footage this entry's `url` serves progressively. See [HlsImetaTag].
val hls: String? = null,
val fallback: List<String> = emptyList(),
val image: List<String> = emptyList(),
val thumbhash: String? = null,
@@ -60,6 +63,7 @@ data class VideoMeta(
blurhash?.let { blurhash(it) }
thumbhash?.let { thumbhash(it) }
service?.let { service(it) }
hls?.let { hls(it) }
fallback.forEach { fallback(it) }
image.forEach { image(it) }
bitrate?.let { bitrate(it) }
@@ -80,6 +84,7 @@ data class VideoMeta(
hash = iMeta.hash()?.firstOrNull(),
size = iMeta.size()?.firstOrNull()?.toIntOrNull(),
service = iMeta.service()?.firstOrNull(),
hls = iMeta.hls()?.firstOrNull(),
fallback = iMeta.fallback() ?: emptyList(),
image = iMeta.image() ?: emptyList(),
thumbhash = iMeta.thumbhash()?.firstOrNull(),
@@ -0,0 +1,133 @@
/*
* 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.nip71Video.credits
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
/**
* Who and what a video credits, beyond its author.
*
* NIP-71 only defines `p` as "a participant in the video". Everything richer is convention:
* divine.video labels each reference with what it is for — a person who inspired the piece, the
* video whose audio was reused, a collaborator who accepted a credit — and puts that word in the
* tag's marker slot. Reading the marker is what separates "featuring @x" from a bare mention.
*/
@Immutable
data class VideoCredit(
val target: CreditTarget,
/**
* The marker as published: `inspired-by`, `mention`, `audio`, or a role like `Collaborator`.
* Null when the tag names a participant without saying in what capacity.
*/
val label: String?,
)
@Immutable
sealed interface CreditTarget {
@Immutable
data class Person(
val pubKey: HexKey,
val relay: NormalizedRelayUrl? = null,
) : CreditTarget
@Immutable
data class Video(
val address: ATag,
) : CreditTarget
@Immutable
data class Event(
val eventId: HexKey,
val relay: NormalizedRelayUrl? = null,
) : CreditTarget
}
object VideoCredits {
// NIP-10's own markers on an `e`/`a` tag describe threading, not authorship. A video is never
// a reply, so any of these is either a stray or something other than a credit — reading them
// as one would print "root" under the player as though it were a person's role.
private val THREADING_MARKERS = setOf("root", "reply")
/**
* Reads every credited reference off a video's tags, in published order.
*
* The marker's *position* is not fixed, because two conventions collide: divine-mobile writes
* `["p", <pubkey>, <relay>, "inspired-by"]` while divine-web's collaborator invite writes
* `["p", <pubkey>, "Collaborator"]`. Both are unambiguous once you ask whether slot 2 parses
* as a relay — the same test [PTag] already uses to decide whether it holds a hint.
*/
fun parse(tags: TagArray): List<VideoCredit> =
tags.mapNotNull { tag ->
when {
tag.size > 1 && tag[0] == PTag.TAG_NAME && tag[1].length == 64 ->
VideoCredit(CreditTarget.Person(tag[1], relayHintAt(tag, 2)), labelAfterOptionalRelay(tag))
tag.size > 1 && tag[0] == ATag.TAG_NAME ->
ATag.parse(tag[1], tag.getOrNull(2))?.let { address ->
val label = labelAfterOptionalRelay(tag)
if (label in THREADING_MARKERS) null else VideoCredit(CreditTarget.Video(address), label)
}
tag.size > 1 && tag[0] == ETag.TAG_NAME && tag[1].length == 64 -> {
// Unlike `p` and `a`, a bare `e` tag on a video says nothing — it is only a
// credit when something names what the reference is for (divine.video writes
// "audio" for a reused soundtrack). No marker, no credit.
val label = labelAfterOptionalRelay(tag)
if (label == null || label in THREADING_MARKERS) {
null
} else {
VideoCredit(CreditTarget.Event(tag[1], relayHintAt(tag, 2)), label)
}
}
else -> null
}
}
private fun relayHintAt(
tag: Array<String>,
index: Int,
): NormalizedRelayUrl? {
if (!tag.has(index)) return null
if (tag[index].length <= 7 || !RelayUrlNormalizer.isRelayUrl(tag[index])) return null
return RelayUrlNormalizer.normalizeOrNull(tag[index])
}
// Slot 2 is either a relay hint or the label itself; the label follows it when it is a relay.
private fun labelAfterOptionalRelay(tag: Array<String>): String? {
val slot2 = tag.getOrNull(2)
val label =
if (slot2 != null && slot2.length > 7 && RelayUrlNormalizer.isRelayUrl(slot2)) {
tag.getOrNull(3)
} else {
slot2
}
return label?.takeIf { it.isNotBlank() }
}
}
@@ -0,0 +1,40 @@
/*
* 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.nip71Video.tags
/**
* Non-standard `hls` property inside a NIP-71 `imeta` tag: the adaptive HLS manifest for the
* same footage the entry's `url` serves progressively.
*
* NIP-71 itself expects a ladder to be published as separate `imeta` entries, one of which
* carries an HLS media type. divine.video instead keeps one `imeta` per video and hangs the
* manifest off this key (see `divine-web/src/lib/videoParser.ts`), so a client that only reads
* `url` is pinned to the progressive rendition and never adapts. Quartz reads both shapes;
* `selectVideoTrack()` turns an `hls` property into a playable variant of its own.
*/
class HlsImetaTag {
companion object {
const val TAG_NAME = "hls"
/** The media type an `hls` url is served with; NIP-71's own ladder example uses it too. */
const val MIME_TYPE = "application/vnd.apple.mpegurl"
}
}
@@ -20,16 +20,34 @@
*/
package com.vitorpamplona.quartz.nip71Video.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-71 `text-track`: supplementary timed text for a video (captions, subtitles, chapters,
* metadata).
*
* The spec is inconsistent about the payload — its prose calls [ref] a "link to WebVTT file"
* while its example writes an encoded event — and publishers use both. divine.video emits a
* Blossom URL and an addressable `39307:<pubkey>:subtitles:<d>` coordinate for the same track,
* so [ref] is deliberately untyped: whatever identifies the track. Positions 3 and 4 carry the
* kind of information and its language code, per the prose.
*/
data class TextTrackTag(
val eventId: HexKey,
var relay: String? = null,
val ref: String,
val relay: String? = null,
val type: String? = null,
val language: String? = null,
) {
fun toTagArray() = arrayOfNotNull(TAG_NAME, eventId, relay)
// Positions are meaningful, so a gap before a field that IS set has to be written as an
// empty string rather than dropped — otherwise `language` would be read as `type`.
fun toTagArray(): Array<String> =
when {
language != null -> arrayOf(TAG_NAME, ref, relay ?: "", type ?: "", language)
type != null -> arrayOf(TAG_NAME, ref, relay ?: "", type)
relay != null -> arrayOf(TAG_NAME, ref, relay)
else -> arrayOf(TAG_NAME, ref)
}
companion object {
const val TAG_NAME = "text-track"
@@ -38,12 +56,19 @@ data class TextTrackTag(
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return TextTrackTag(tag[1], tag.getOrNull(2))
return TextTrackTag(
ref = tag[1],
relay = tag.getOrNull(2)?.ifBlank { null },
type = tag.getOrNull(3)?.ifBlank { null },
language = tag.getOrNull(4)?.ifBlank { null },
)
}
fun assemble(
eventId: HexKey,
ref: String,
relay: String?,
) = arrayOfNotNull(TAG_NAME, eventId, relay)
type: String? = null,
language: String? = null,
) = TextTrackTag(ref, relay, type, language).toTagArray()
}
}
@@ -0,0 +1,46 @@
/*
* 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.nip71Video.textTrack
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The bare `["l", "<code>"]` language tag a [TextTrackEvent] carries.
*
* NIP-32 spells a label as `["l", "<value>", "<namespace>"]`; publishers of subtitle events drop
* the namespace and use the slot for a plain ISO-639-1 code, so this reads position 1 and ignores
* whatever follows.
*/
class LanguageTag {
companion object {
const val TAG_NAME = "l"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(languageCode: String) = arrayOf(TAG_NAME, languageCode)
}
}
@@ -0,0 +1,103 @@
/*
* 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.nip71Video.textTrack
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* An addressable timed-text track for a NIP-71 video — captions, subtitles, chapters or
* metadata — referenced from the video's `text-track` tag.
*
* NIP-71 only says a `text-track` points at "a WebVTT file"; publishers that want the track to be
* replaceable (retranslated, corrected) wrap it in this event instead, and reference it by
* coordinate. divine.video does both at once: the same track appears on the video as a raw
* Blossom URL *and* as `39307:<pubkey>:subtitles:<video-d>`.
*
* The cue text lives in [content] as WebVTT, which makes the event self-contained; [url] is the
* same document hosted as a file. Prefer the URL when there is one — the player streams it and
* only parses the cues it needs — and fall back to the inline copy when the host is gone.
*/
@Immutable
class TextTrackEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
SearchableEvent {
// The cue text is the only searchable thing here: the `d` tag is a slug and the rest is
// plumbing. Indexing it makes a video findable by what is said in it.
override fun indexableContent() = content
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
visitor.visit(content)
}
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
/** The video this track belongs to. */
fun video() = tags.firstNotNullOfOrNull(ATag::parseAddress)
fun url() = tags.firstNotNullOfOrNull(UrlTag::parse)
fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse)
/** ISO-639-1 code, from the NIP-32 style single-letter `l` tag publishers use here. */
fun language() = tags.firstNotNullOfOrNull(LanguageTag::parse)
companion object {
const val KIND = 39307
const val DEFAULT_MIME_TYPE = "text/vtt"
/** `subtitles:<video d tag>` — the convention divine.video publishes under. */
fun identifierFor(
type: String,
videoDTag: String,
) = "$type:$videoDTag"
fun build(
webVtt: String,
dTag: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<TextTrackEvent>.() -> Unit = {},
) = eventTemplate(KIND, webVtt, createdAt) {
dTag(dTag)
initializer()
}
}
}
@@ -32,6 +32,10 @@ class OptionTag(
companion object {
const val TAG_NAME = "option"
// Both fields are required. NIP-88 defines an option as an id "followed by an option
// label field", and an option nobody can read is not a choice anyone can make: rendering
// it as a blank, tappable row would collect votes for a question the voter never saw.
// A poll that ships one is malformed, and the option is dropped rather than shown empty.
fun isTag(tag: Array<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty()
fun parse(tag: Array<String>): OptionTag? {
@@ -34,7 +34,9 @@ class PollTypeTag {
companion object {
const val TAG_NAME = "polltype"
fun isTag(tag: Array<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
// has(1), matching parse: NIP-88's tag is `["polltype", "<type>"]` with nothing after it,
// so requiring a third element made this answer false for every conformant tag.
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): PollType? {
ensure(tag.has(1)) { return null }
@@ -61,11 +61,48 @@ class PollResponseEvent(
companion object {
const val KIND = 1018
fun build(
/**
* The answer to a single-choice poll.
*
* NIP-88: "polltype: singlechoice: The first response tag is to be considered the actual
* response." One code in, one `response` tag out — there is no way to express an answer
* whose meaning depends on which tag the reader happens to look at first.
*/
fun buildSingleChoice(
poll: EventHintBundle<PollEvent>,
response: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = build(poll, listOf(response), createdAt, initializer)
/**
* The answer to a multiple-choice poll.
*
* NIP-88: "the first response tag pointing to each id is considered the actual response,
* without considering the order of the response tags" — so a Set is exactly the right
* shape here, and the conversion to a list below is only about writing them down.
*/
fun buildMultipleChoice(
poll: EventHintBundle<PollEvent>,
responses: Set<String>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = build(poll, responses.toList(), createdAt, initializer)
/**
* Writes [responses] as `response` tags in the order given.
*
* Prefer [buildSingleChoice] / [buildMultipleChoice]: the poll type decides whether order
* matters, and this overload cannot know which one it is being used for. It takes a List
* rather than a Set so that a caller who does know keeps control of the order — passing an
* unordered Set here would hand a single-choice answer to whatever iteration order the
* collection happened to have.
*/
fun build(
poll: EventHintBundle<PollEvent>,
responses: List<String>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
poll(poll)
notifyAuthor(poll)
@@ -29,6 +29,6 @@ import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag
fun TagArrayBuilder<PollResponseEvent>.poll(pollHint: EventHintBundle<PollEvent>) = addUnique(PollTag.assemble(pollHint))
fun TagArrayBuilder<PollResponseEvent>.responses(responses: Set<String>) = addAll(ResponseTag.assemble(responses))
fun TagArrayBuilder<PollResponseEvent>.responses(responses: List<String>) = addAll(ResponseTag.assemble(responses))
fun TagArrayBuilder<PollResponseEvent>.notifyAuthor(pollHint: EventHintBundle<PollEvent>) = add(pollHint.toPTag().toTagArray())
@@ -38,6 +38,9 @@ class ResponseTag {
fun assemble(code: String) = arrayOf(TAG_NAME, code)
fun assemble(responses: Set<String>) = responses.map { code -> assemble(code) }
// A List, not a Set: NIP-88 reads a single-choice answer as "the first response tag",
// so the order these are written in is load-bearing and an unordered Set would leave the
// recorded answer to iteration order.
fun assemble(responses: List<String>) = responses.map { code -> assemble(code) }
}
}
@@ -155,6 +155,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.addressables.Addressab
import com.vitorpamplona.quartz.experimental.trustedLists.events.EventTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.ExternalIdTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -333,6 +334,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
@@ -871,6 +873,8 @@ class EventFactory {
VideoCurationSetEvent.KIND -> VideoCurationSetEvent(id, pubKey, createdAt, tags, content, sig)
VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig)
VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig)
TextTrackEvent.KIND -> TextTrackEvent(id, pubKey, createdAt, tags, content, sig)
VideoCollaborationEvent.KIND -> VideoCollaborationEvent(id, pubKey, createdAt, tags, content, sig)
VideoNormalEvent.KIND -> VideoNormalEvent(id, pubKey, createdAt, tags, content, sig)
VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig)
VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,215 @@
/*
* 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.nip88Polls
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.endsAt
import com.vitorpamplona.quartz.nip88Polls.poll.options
import com.vitorpamplona.quartz.nip88Polls.poll.pollType
import com.vitorpamplona.quartz.nip88Polls.poll.relays
import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip88Polls.response.poll
import com.vitorpamplona.quartz.nip88Polls.response.responses
import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag
import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The NIP-88 tag layer against the spec's own example events.
*
* Both halves are checked, because a parser and a serializer can be wrong in opposite directions
* and still agree with each other: the examples are parsed field by field, and what the builders
* emit is compared to the literal tag arrays the spec prints.
*/
class Nip88PollTagsTest {
// Verbatim from NIP-88, "Poll Event".
private val specPoll =
"""{
"content": "Pineapple on pizza",
"created_at": 1719888496,
"id": "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634",
"kind": 1068,
"pubkey": "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b",
"sig": "7fa93bf3c430eaef784b0dacc217d3cd5eff1c520e7ef5d961381bc0f014dde6286618048d924808e54d1be03f2f2c2f0f8b5c9c2082a4480caf45a565ca9797",
"tags": [
["option", "qj518h583", "Yay"],
["option", "gga6cdnqj", "Nay"],
["relay", "wss://relay.damus.io"],
["relay", "wss://nos.lol"],
["polltype", "singlechoice"],
["endsAt", "1719988496"]
]
}"""
// Verbatim from NIP-88, "Responses".
private val specResponse =
"""{
"content": "",
"created_at": 1720097117,
"id": "60a005e32e9596c3f544a841a9bc4e46d3020ca3650d6a739c95c1568e33f6d8",
"kind": 1018,
"pubkey": "1bc70a0148b3f316da33fe7e89f23e3e71ac4ff998027ec712b905cd24f6a411",
"sig": "30071a633c65db8f3a075c7a8de757fbd8ce65e3607f4ba287fe6d7fbf839a380f94ff4e826fbba593f6faaa13683b7ea9114ade140720ecf4927010ebf3e44f",
"tags": [
["e", "1fc80cf813f1af33d5a435862b7ef7fb96b47e68a48f1abcadf8081f5a545550"],
["response", "gga6cdnqj"],
["response", "m3agjsdq1"]
]
}"""
@Test
fun theSpecsPollExampleParsesFieldForField() {
val event = Event.fromJson(specPoll)
assertTrue(event is PollEvent)
val poll = event as PollEvent
assertEquals("Pineapple on pizza", poll.content)
assertEquals(listOf("qj518h583" to "Yay", "gga6cdnqj" to "Nay"), poll.options().map { it.code to it.label })
assertEquals(listOf("wss://relay.damus.io/", "wss://nos.lol/"), poll.relays().map { it.url })
assertEquals(PollType.SINGLE_CHOICE, poll.pollType())
assertEquals(1719988496L, poll.endsAt())
}
@Test
fun theSpecsResponseExampleParsesFieldForField() {
val event = Event.fromJson(specResponse)
assertTrue(event is PollResponseEvent)
val response = event as PollResponseEvent
assertEquals("1fc80cf813f1af33d5a435862b7ef7fb96b47e68a48f1abcadf8081f5a545550", response.poll()?.eventId)
// Both tags are read. Which of them counts is the polltype's business, not the parser's.
assertEquals(listOf("gga6cdnqj", "m3agjsdq1"), response.responses())
}
@Test
fun aPollWithNoPollTypeIsSingleChoice() {
// "Polls that do not have a polltype should be considered a singlechoice poll."
val untyped = Event.fromJson(specPoll.replace("""["polltype", "singlechoice"],""", ""))
assertEquals(PollType.SINGLE_CHOICE, (untyped as PollEvent).pollType())
// An unrecognised one falls back the same way rather than being treated as multi-choice,
// which would let one response cast several votes.
val unknown = Event.fromJson(specPoll.replace("singlechoice", "rankedchoice"))
assertEquals(PollType.SINGLE_CHOICE, (unknown as PollEvent).pollType())
}
@Test
fun aWellFormedPollTypeTagIsRecognisedAsOne() {
// isTag and parse have to agree: the spec's tag has exactly two elements, and a stricter
// isTag makes `tags.any(PollTypeTag::isTag)` answer "no poll declares a type".
assertTrue(PollTypeTag.isTag(arrayOf("polltype", "singlechoice")))
assertTrue(PollTypeTag.isTag(arrayOf("polltype", "multiplechoice")))
assertFalse(PollTypeTag.isTag(arrayOf("polltype")))
assertFalse(PollTypeTag.isTag(arrayOf("endsAt", "1719988496")))
}
@Test
fun anOptionNeedsBothAnIdAndALabel() {
assertEquals("qj518h583" to "Yay", OptionTag.parse(arrayOf("option", "qj518h583", "Yay"))?.let { it.code to it.label })
// NIP-88 defines an option as an id "followed by an option label field", and an option
// nobody can read is not a choice anyone can make — a blank tappable row would collect
// votes for a question the voter never saw. Both fields are required, and isTag agrees
// with parse about that.
assertNull(OptionTag.parse(arrayOf("option", "qj518h583", "")))
assertNull(OptionTag.parse(arrayOf("option", "qj518h583")))
assertNull(OptionTag.parse(arrayOf("option", "", "Yay")))
assertNull(OptionTag.parse(arrayOf("option")))
assertNull(OptionTag.parse(arrayOf("response", "qj518h583", "Yay")))
assertTrue(OptionTag.isTag(arrayOf("option", "qj518h583", "Yay")))
assertFalse(OptionTag.isTag(arrayOf("option", "qj518h583", "")))
assertFalse(OptionTag.isTag(arrayOf("option", "qj518h583")))
}
@Test
fun theBuildersEmitTheTagsTheSpecPrints() {
val template =
PollEvent.build(
description = "Pineapple on pizza",
options = listOf(OptionTag("qj518h583", "Yay"), OptionTag("gga6cdnqj", "Nay")),
endsAt = 1719988496L,
relays = listOfNotNull(RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")),
pollType = PollType.SINGLE_CHOICE,
)
assertEquals("Pineapple on pizza", template.content)
assertEquals(PollEvent.KIND, template.kind)
val tags = template.tags.associateBy({ it[0] }, { it.toList() })
assertContentEquals(listOf("polltype", "singlechoice"), tags["polltype"])
assertContentEquals(listOf("endsAt", "1719988496"), tags["endsAt"])
assertContentEquals(listOf("relay", "wss://relay.damus.io/"), tags["relay"])
assertContentEquals(
listOf(listOf("option", "qj518h583", "Yay"), listOf("option", "gga6cdnqj", "Nay")),
template.tags.filter { it[0] == "option" }.map { it.toList() },
)
}
@Test
fun aSingleChoiceAnswerCanOnlyBeOneTag() {
val poll = EventHintBundle(Event.fromJson(specPoll) as PollEvent, null)
val template = PollResponseEvent.buildSingleChoice(poll, "gga6cdnqj")
val responses = template.tags.filter { it[0] == "response" }
// The spec reads the FIRST response tag as the answer, so a single-choice event that
// carries more than one is ambiguous by construction. The typed builder cannot emit one.
assertEquals(listOf(listOf("response", "gga6cdnqj")), responses.map { it.toList() })
assertEquals(PollResponseEvent.KIND, template.kind)
}
@Test
fun aMultipleChoiceAnswerWritesEveryCodeItWasGiven() {
val poll = EventHintBundle(Event.fromJson(specPoll) as PollEvent, null)
val template = PollResponseEvent.buildMultipleChoice(poll, setOf("qj518h583", "gga6cdnqj"))
val codes = template.tags.filter { it[0] == "response" }.map { it[1] }
assertEquals(setOf("qj518h583", "gga6cdnqj"), codes.toSet())
}
@Test
fun theTagNamesAreSpelledTheWayTheSpecSpellsThem() {
// `endsAt` is camelCase in NIP-88 while almost every other nostr tag is lowercase, so it
// is the one most likely to be "corrected" into something no other client reads.
assertEquals("endsAt", EndsAtTag.TAG_NAME)
assertEquals("polltype", PollTypeTag.TAG_NAME)
assertEquals("option", OptionTag.TAG_NAME)
assertEquals("relay", RelayTag.TAG_NAME)
assertEquals("response", ResponseTag.TAG_NAME)
assertEquals("e", PollTag.TAG_NAME)
assertEquals("singlechoice", PollType.SINGLE_CHOICE.code)
assertEquals("multiplechoice", PollType.MULTI_CHOICE.code)
}
}
@@ -31,7 +31,7 @@ import java.io.File
* `indexableContent()` is not an internal detail: the SQLite and filesystem stores index through
* it, and `references/searchable-kinds.md` — which external engines mirror at version bumps — is a
* transcription of it. A body that changes silently ships stale results downstream and needs a
* `reindexFullTextSearch()` on every existing database. So the output of all 126 kinds is recorded
* `reindexFullTextSearch()` on every existing database. So the output of all 127 kinds is recorded
* in `indexable-content.golden` and compared here.
*
* A deliberate change to what a kind indexes is fine — regenerate the golden file with
@@ -137,6 +137,7 @@
39000 The Name\nThe About
39089 The Title\nThe Description
39092 The Title\nThe Description
39307 The content body.
39701 The Title\nThe content body.
40002 The content body.
40100 The content body.