mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat: find collaboration acceptances by their a tag, and apply late captions
Two gaps found reviewing this branch against what divine.video actually publishes, plus the desktop half of the poll-builder change. Collaboration acceptances (kind 34238) were looked up by a coordinate we predicted — `34238:<collaborator>:<video coordinate>`. divine-mobile does key them that way, but divine-web signs its acceptance with `['d', crypto.randomUUID()]` (divine-web/src/hooks/useApproveCollab.ts:24), so no coordinate a reader can construct will ever name it. The lookup now filters on `kinds:[34238] + authors + #a:[video coord]`, which is the same filter divine-web itself uses (src/hooks/useVideoCollaboratorStatus.ts) and matches both publishers. Kind 34238 joins the `a`-tag engagement filter every rendered addressable note already opens, so the relay side costs no extra REQ, and the credit's check mark now reads LocalCache instead of opening a subscription per credit — strictly fewer REQs than before. Captions that resolve after the first frame never reached the player. A `text-track` naming a kind-39307 coordinate rebuilds the MediaItem with the same videoUri once that event arrives, and the warm-pool fast path reads the unchanged mediaId as "already loaded". A MediaController doesn't carry the item's localConfiguration across the service IPC, so the subtitles can't be read back off it to compare; GetVideoController remembers the fingerprint it last pushed instead, and re-applies at the current position only when it was the one that loaded the item — a warm player carrying another mount's item is left alone rather than rebuffered. desktopApp's voteOnPoll still called PollResponseEvent.build with a Set. It now dispatches on pollType() the way Account.pollRespond does, so a single-choice vote writes one response tag rather than leaving the recorded answer to iteration order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLEq2G6U29mKV8cfSTamZ9
This commit is contained in:
@@ -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
|
||||
|
||||
+4
@@ -186,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()
|
||||
|
||||
+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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-17
@@ -147,25 +147,39 @@ fun RenderTopButtons(
|
||||
val hasMultipleQualities = videoGroup != null && videoGroup.length > 1
|
||||
|
||||
// Captions are side-loaded from the event's `text-track` tags (MediaItemCache flags the first
|
||||
// one default), so the button reflects whether the player currently has the text track on.
|
||||
// Toggling disables the whole track type rather than deselecting one group: with a single
|
||||
// side-loaded track those are the same thing, and the type-level switch survives the player
|
||||
// picking a different track later.
|
||||
//
|
||||
// The choice lives on the player instance, so it holds for as long as that player does —
|
||||
// including the next video the warm pool hands it to — and resets when it is released. Making
|
||||
// it stick across sessions would mean another synced setting; this is the scope of a button.
|
||||
var captionsEnabled by remember(player) { mutableStateOf(!player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)) }
|
||||
// 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(player) {
|
||||
remember(captionsEnabled, captionChoices) {
|
||||
{
|
||||
val enabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)
|
||||
player.trackSelectionParameters =
|
||||
player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !enabled)
|
||||
.build()
|
||||
captionsEnabled = enabled
|
||||
if (captionChoices.size > 1) {
|
||||
captionsPopupOpen.value = true
|
||||
} else {
|
||||
accountViewModel.setCaptionsEnabled(!captionsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +256,25 @@ fun RenderTopButtons(
|
||||
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,
|
||||
|
||||
+4
-5
@@ -105,11 +105,10 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
|
||||
.setUri(key.videoUri)
|
||||
.apply { normalizedMime?.let { setMimeType(it) } }
|
||||
.setSubtitleConfigurations(
|
||||
// Feed videos autoplay muted, so a caption track is what makes them
|
||||
// legible — flag the first one default and let ExoPlayer select it, and
|
||||
// RenderCaptions draws the cues. There is no way to turn them off yet:
|
||||
// the player's button row is a user-configured set, so a CC toggle needs
|
||||
// to be added there rather than hard-coded into the overlay.
|
||||
// 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)
|
||||
},
|
||||
|
||||
+25
-13
@@ -34,11 +34,11 @@ 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.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -49,6 +49,7 @@ 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
|
||||
@@ -108,9 +109,9 @@ private fun RenderVideoCredit(
|
||||
name = "@" + it.toBestDisplayName(),
|
||||
confirmed = {
|
||||
// Only a role-marked credit is an invite someone can accept, and each
|
||||
// lookup costs a LocalCache note plus a relay REQ per credit per
|
||||
// rendered card. Asking for every `mention` and `inspired-by` would
|
||||
// spend a subscription per feed item on an event that cannot exist.
|
||||
// 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)
|
||||
}
|
||||
@@ -141,11 +142,15 @@ private fun RenderVideoCredit(
|
||||
/**
|
||||
* Draws a check when [pubKey] has published an accepted [VideoCollaborationEvent] for this video.
|
||||
*
|
||||
* The response is addressed by its own coordinate — `34238:<collaborator>:<video coordinate>` —
|
||||
* so it can be asked for directly instead of scanning every 34238 on the network for one that
|
||||
* `a`-tags this video. A publisher that keys the response by something else (divine-web uses a
|
||||
* random `d`) simply never resolves here, and the credit renders without the check rather than
|
||||
* with a wrong one.
|
||||
* 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(
|
||||
@@ -155,12 +160,19 @@ private fun AcceptedCollaboration(
|
||||
) {
|
||||
if (videoAddress == null) return
|
||||
|
||||
val responseAddress =
|
||||
val responseFlow =
|
||||
remember(pubKey, videoAddress) {
|
||||
Address(VideoCollaborationEvent.KIND, pubKey, videoAddress.toValue())
|
||||
accountViewModel.account.cache.observeLatestEvent<VideoCollaborationEvent>(
|
||||
Filter(
|
||||
kinds = listOf(VideoCollaborationEvent.KIND),
|
||||
authors = listOf(pubKey),
|
||||
tags = mapOf("a" to listOf(videoAddress.toValue())),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
val note = remember(responseAddress) { accountViewModel.getOrCreateAddressableNote(responseAddress) }
|
||||
val response by observeNoteEvent<VideoCollaborationEvent>(note, accountViewModel)
|
||||
|
||||
val response by responseFlow.collectAsStateWithLifecycle(null)
|
||||
|
||||
if (response?.isAccepted() == true) {
|
||||
Icon(
|
||||
|
||||
+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)
|
||||
|
||||
@@ -2789,6 +2789,8 @@
|
||||
<!-- 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>
|
||||
|
||||
+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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+47
-2
@@ -26,6 +26,7 @@ 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
|
||||
@@ -35,6 +36,7 @@ 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
|
||||
@@ -291,11 +293,54 @@ class DivineVideoInteropTest {
|
||||
)
|
||||
assertEquals("Collaborator", response.role())
|
||||
assertTrue(response.isAccepted())
|
||||
// Keyed by the video coordinate, so the credited person's answer can be addressed directly
|
||||
// instead of scanning every 34238 for one that points back here.
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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
|
||||
@@ -175,6 +176,29 @@ class Nip88PollTagsTest {
|
||||
)
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user