diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1801a0cdd8..3ddf690bc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) { if (settings.changeVideoPlayerButtonItems(items)) { sendNewAppSpecificData() @@ -2309,7 +2316,15 @@ class Account( val poll = cache.getOrCreateNote(event.id).toEventHint() 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index c95b1e3f66..f5ab845350 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index afedafbff2..33b42278ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -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>, + val captionsEnabled: MutableStateFlow, ) @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index c8e1ef7082..4c4fa3b252 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -186,6 +186,10 @@ class AccountNavigationPreferencesInternal( @Serializable class AccountVideoPlayerPreferencesInternal( var buttonItems: List = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 79478bdc34..e4d6a6d811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -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(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/CaptionLanguageControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/CaptionLanguageControls.kt new file mode 100644 index 0000000000..8da9a6c2ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/CaptionLanguageControls.kt @@ -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 { + val choices = mutableListOf() + 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, + 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), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index a3177fd599..cd27ed83fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt index cb35f71e63..9a3096fe8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt @@ -105,11 +105,10 @@ class MediaItemCache : GenericBaseCache(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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCredits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCredits.kt index 558421bfdf..2c8521a293 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCredits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCredits.kt @@ -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::