diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index 8cfa8328c9..c8cab211a5 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -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` | 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 f9b8624662..4c4fa3b252 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -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 = 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/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 29bbade1a0..7cab03252e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 798166ddb3..bde715f14d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -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 = 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 = 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 0c28d4ad71..020af20e41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -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 = 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, 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/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index 8b9c093e0a..5615a35f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -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() + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCaptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCaptions.kt new file mode 100644 index 0000000000..20c2f22431 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCaptions.kt @@ -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>(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) 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 bdc0fafe6d..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 @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt index 003e4dae1a..e777945a9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt @@ -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 = 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(null) } + val current = mediaItem + if (current != null && previous.value !== current) { + SideEffect { previous.value = current } + } + + (current ?: previous.value)?.let { inner(it) } } 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 23cf7c3168..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 @@ -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(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/` 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(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 }) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt index 7c771951ce..a140f0b37e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt @@ -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 = persistentListOf(), ) @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index d9ec09d6e9..e8301b616c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 50698249d0..8781527596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -523,6 +523,7 @@ private fun RenderImageOrVideo( controllerVisible = controllerVisible, hasBlurhash = content.blurhash != null, isFullscreen = true, + captions = content.captions, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 4e78bf955f..2e506c7b01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -277,6 +277,7 @@ fun ZoomableContentView( thumbhash = content.thumbhash, isLiveStream = content.isLiveStream, hash = content.hash, + captions = content.captions, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a793682bd0..ff2ed72696 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RememberCaptionTracks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RememberCaptionTracks.kt new file mode 100644 index 0000000000..15402138f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RememberCaptionTracks.kt @@ -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::` 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 { + 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(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() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCollaboration.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCollaboration.kt new file mode 100644 index 0000000000..264a2d73f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCollaboration.kt @@ -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, + 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, + ) + } + } + } + } +} 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 new file mode 100644 index 0000000000..2c8521a293 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderVideoCredits.kt @@ -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::