mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+25
@@ -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()
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+8
-1
@@ -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,
|
||||
)
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+157
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -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)
|
||||
+87
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+31
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -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 })
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+55
-4
@@ -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 {
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
@@ -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
|
||||
+217
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+148
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -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 }
|
||||
+163
@@ -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) }
|
||||
}
|
||||
}
|
||||
+6
@@ -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)
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+4
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+104
@@ -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 }
|
||||
+16
-1
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+24
-1
@@ -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 =
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+46
@@ -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
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+375
@@ -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())
|
||||
}
|
||||
}
|
||||
+36
@@ -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.
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+112
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+43
@@ -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
|
||||
|
||||
+7
-2
@@ -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()
|
||||
|
||||
+7
-2
@@ -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(),
|
||||
|
||||
+133
@@ -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"
|
||||
}
|
||||
}
|
||||
+33
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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? {
|
||||
|
||||
+3
-1
@@ -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 }
|
||||
|
||||
+38
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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())
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+215
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user