From d0b2af752e4e4bc56777507ce46ee4e1a4c84c8c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 04:48:43 +0000 Subject: [PATCH] feat: add configurable video player buttons Mirror the existing reaction row settings pattern. Each of the six configurable video player buttons (Fullscreen, Mute, Quality, Share, Download, PictureInPicture) can be placed either in the top bar or in the overflow menu, with drag-to-reorder within its chosen location. - Data model: VideoPlayerAction, VideoButtonLocation, VideoPlayerButtonItem added to AccountSyncedSettingsInternal so the config syncs via NIP-78. - StateFlow wrapper AccountVideoPlayerPreferences plumbed through AccountSettings, Account, and AccountViewModel. - New settings screen at Route.VideoPlayerSettings mirroring the reaction row screen (drag-to-reorder list with per-row FilterChip location). - RenderTopButtons partitions configured buttons into top-bar vs overflow lists; overflow icon is hidden entirely when empty. Existing availability rules are preserved (Fullscreen needs zoom handler, Quality needs HLS with multiple renditions, Download hidden on live streams, PictureInPicture hidden without device support). - Volume is now observed via Player.Listener.onVolumeChanged so the overflow Mute row stays in sync with the current state. - VideoQualityPopup extracted so the quality chooser can open from the overflow menu too. --- .../vitorpamplona/amethyst/model/Account.kt | 6 + .../amethyst/model/AccountSettings.kt | 9 + .../amethyst/model/AccountSyncedSettings.kt | 15 + .../model/AccountSyncedSettingsInternal.kt | 38 ++ .../composable/controls/OverflowMenu.kt | 127 +++++-- .../composable/controls/RenderTopButtons.kt | 268 +++++++++++--- .../composable/controls/VideoQualityButton.kt | 79 ++-- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../ui/screen/loggedIn/AccountViewModel.kt | 7 + .../loggedIn/settings/AllSettingsScreen.kt | 8 + .../settings/VideoPlayerSettingsScreen.kt | 345 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 18 + 13 files changed, 801 insertions(+), 123 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt 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 04ff884a5..eac9f06f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -554,6 +554,12 @@ class Account( } } + suspend fun changeVideoPlayerButtonItems(items: List) { + if (settings.changeVideoPlayerButtonItems(items)) { + sendNewAppSpecificData() + } + } + suspend fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, 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 10521231f..8ba8f980c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -267,6 +267,15 @@ class AccountSettings( return false } + fun changeVideoPlayerButtonItems(newItems: List): Boolean { + if (syncedSettings.videoPlayer.buttonItems.value != newItems) { + syncedSettings.videoPlayer.buttonItems.tryEmit(newItems.toImmutableList()) + saveAccountSettings() + return true + } + return false + } + fun defaultNwcWallet(): NwcWalletEntryNorm? { val id = defaultNwcWalletId.value val wallets = nwcWallets.value 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 fae2ef54c..adc3299ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -56,6 +56,10 @@ class AccountSyncedSettings( MutableStateFlow(internalSettings.security.maxHashtagLimit), MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay), ) + val videoPlayer = + AccountVideoPlayerPreferences( + MutableStateFlow(internalSettings.videoPlayer.buttonItems.toImmutableList()), + ) fun toInternal(): AccountSyncedSettingsInternal = AccountSyncedSettingsInternal( @@ -79,6 +83,7 @@ class AccountSyncedSettings( security.maxHashtagLimit.value, security.sendKind0EventsToLocalRelay.value, ), + videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -132,6 +137,11 @@ class AccountSyncedSettings( if (security.sendKind0EventsToLocalRelay.value != syncedSettingsInternal.security.sendKind0EventsToLocalRelay) { security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay) } + + val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList() + if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) { + videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -143,6 +153,11 @@ class AccountReactionPreferences( var reactionRowItems: MutableStateFlow>, ) +@Stable +class AccountVideoPlayerPreferences( + var buttonItems: MutableStateFlow>, +) + @Stable class AccountZapPreferences( var zapAmountChoices: MutableStateFlow>, 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 8bd329898..86f9b3c1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -66,6 +66,38 @@ val DefaultReactionRowItems = ReactionRowItem(ReactionRowAction.Pay, showCounter = false), ) +@Serializable +enum class VideoPlayerAction { + Fullscreen, + Mute, + Quality, + Share, + Download, + PictureInPicture, +} + +@Serializable +enum class VideoButtonLocation { + TopBar, + OverflowMenu, +} + +@Serializable +data class VideoPlayerButtonItem( + val action: VideoPlayerAction, + val location: VideoButtonLocation = VideoButtonLocation.OverflowMenu, +) + +val DefaultVideoPlayerButtonItems = + listOf( + VideoPlayerButtonItem(VideoPlayerAction.Fullscreen, VideoButtonLocation.TopBar), + VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar), + VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar), + VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu), + VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu), + VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu), + ) + fun getLanguagesSpokenByUser(): Set { val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) val codedList = mutableSetOf() @@ -81,6 +113,12 @@ class AccountSyncedSettingsInternal( val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(), val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(), val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(), + val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(), +) + +@Serializable +class AccountVideoPlayerPreferencesInternal( + var buttonItems: List = DefaultVideoPlayerButtonItems, ) @Serializable 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 194b8f499..77cbaf2cd 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 @@ -28,10 +28,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.ZoomOutMap import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -44,6 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.VideoPlayerAction import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection @@ -63,9 +68,11 @@ fun OverflowMenuButtonPreview() { ThemeComparisonColumn { Box(Modifier.background(BitcoinOrange)) { OverflowMenuButton( - showShare = true, - showSave = true, - showPip = true, + actions = listOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture), + startingMuteState = false, + onFullscreenClick = {}, + onMuteClick = {}, + onQualityClick = {}, onShareClick = {}, onSaveClick = {}, onPipClick = {}, @@ -77,13 +84,15 @@ fun OverflowMenuButtonPreview() { @Composable fun AnimatedOverflowMenuButton( controllerVisible: State, - modifier: Modifier = Modifier, - showShare: Boolean = true, - showSave: Boolean = true, - showPip: Boolean = false, + actions: List, + startingMuteState: Boolean, + onFullscreenClick: (() -> Unit)?, + onMuteClick: () -> Unit, + onQualityClick: () -> Unit, onShareClick: () -> Unit, onSaveClick: () -> Unit, onPipClick: () -> Unit, + modifier: Modifier = Modifier, ) { AnimatedVisibility( visible = controllerVisible.value, @@ -92,9 +101,11 @@ fun AnimatedOverflowMenuButton( exit = FadeOut, ) { OverflowMenuButton( - showShare = showShare, - showSave = showSave, - showPip = showPip, + actions = actions, + startingMuteState = startingMuteState, + onFullscreenClick = onFullscreenClick, + onMuteClick = onMuteClick, + onQualityClick = onQualityClick, onShareClick = onShareClick, onSaveClick = onSaveClick, onPipClick = onPipClick, @@ -104,9 +115,11 @@ fun AnimatedOverflowMenuButton( @Composable fun OverflowMenuButton( - showShare: Boolean, - showSave: Boolean, - showPip: Boolean, + actions: List, + startingMuteState: Boolean, + onFullscreenClick: (() -> Unit)?, + onMuteClick: () -> Unit, + onQualityClick: () -> Unit, onShareClick: () -> Unit, onSaveClick: () -> Unit, onPipClick: () -> Unit, @@ -141,31 +154,69 @@ fun OverflowMenuButton( onDismiss = { menuExpanded.value = false }, ) { M3ActionSection { - if (showShare) { - M3ActionRow( - icon = Icons.Default.Share, - text = stringRes(R.string.share_or_save), - ) { - menuExpanded.value = false - onShareClick() - } - } - if (showSave) { - M3ActionRow( - icon = Icons.Default.SaveAlt, - text = stringRes(R.string.download_to_phone), - ) { - menuExpanded.value = false - onSaveClick() - } - } - if (showPip) { - M3ActionRow( - icon = Icons.Default.PictureInPicture, - text = stringRes(R.string.picture_in_picture), - ) { - menuExpanded.value = false - onPipClick() + actions.forEach { action -> + when (action) { + VideoPlayerAction.Fullscreen -> { + onFullscreenClick?.let { zoom -> + M3ActionRow( + icon = Icons.Default.ZoomOutMap, + text = stringRes(R.string.video_player_settings_action_fullscreen), + ) { + menuExpanded.value = false + zoom() + } + } + } + + VideoPlayerAction.Mute -> { + M3ActionRow( + icon = if (startingMuteState) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, + text = if (startingMuteState) stringRes(R.string.muted_button) else stringRes(R.string.mute_button), + ) { + menuExpanded.value = false + onMuteClick() + } + } + + VideoPlayerAction.Quality -> { + M3ActionRow( + icon = Icons.Default.Settings, + text = stringRes(R.string.call_settings_video_quality), + ) { + menuExpanded.value = false + onQualityClick() + } + } + + VideoPlayerAction.Share -> { + M3ActionRow( + icon = Icons.Default.Share, + text = stringRes(R.string.share_or_save), + ) { + menuExpanded.value = false + onShareClick() + } + } + + VideoPlayerAction.Download -> { + M3ActionRow( + icon = Icons.Default.SaveAlt, + text = stringRes(R.string.download_to_phone), + ) { + menuExpanded.value = false + onSaveClick() + } + } + + VideoPlayerAction.PictureInPicture -> { + M3ActionRow( + icon = Icons.Default.PictureInPicture, + text = stringRes(R.string.picture_in_picture), + ) { + menuExpanded.value = false + onPipClick() + } + } } } } 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 8658384f2..903040429 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 @@ -21,18 +21,43 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls import android.content.pm.PackageManager +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PictureInPicture +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.Player +import androidx.media3.common.Tracks import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.model.VideoButtonLocation +import com.vitorpamplona.amethyst.model.VideoPlayerAction import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData @@ -42,7 +67,11 @@ import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Preview @@ -52,6 +81,9 @@ fun RenderTopButtonsPreview() { Box(Modifier.background(BitcoinOrange)) { RenderTopButtons( mediaData = MediaItemData("http://test.mp4"), + videoGroup = null, + hasMultipleQualities = false, + qualityButton = {}, controllerVisible = remember { mutableStateOf(true) }, startingMuteState = false, isLive = false, @@ -59,6 +91,7 @@ fun RenderTopButtonsPreview() { onMuteClick = {}, onPictureInPictureClick = {}, onZoomClick = {}, + onOverflowQualityClick = {}, modifier = Modifier, accountViewModel = mockAccountViewModel(), ) @@ -82,42 +115,80 @@ fun RenderTopButtons( context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } + val player = controllerState.controller + var tracks by remember(player) { mutableStateOf(player.currentTracks) } + var isMuted by remember(player) { mutableStateOf(player.volume < 0.001) } + DisposableEffect(player) { + tracks = player.currentTracks + isMuted = player.volume < 0.001 + val listener = + object : Player.Listener { + override fun onTracksChanged(newTracks: Tracks) { + tracks = newTracks + } + + override fun onVolumeChanged(volume: Float) { + isMuted = volume < 0.001 + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + val videoGroup = getVideoTrackGroup(tracks) + val hasMultipleQualities = videoGroup != null && videoGroup.length > 1 + + val overflowQualityOpen = remember { mutableStateOf(false) } + RenderTopButtons( mediaData = mediaData, + videoGroup = videoGroup, + hasMultipleQualities = hasMultipleQualities, + qualityButton = { + VideoQualityButton( + player = player, + controllerVisible = controllerVisible, + ) + }, controllerVisible = controllerVisible, - startingMuteState = controllerState.controller.volume < 0.001, + startingMuteState = isMuted, isLive = isLive, pipSupported = pipSupported, onMuteClick = { mute -> DEFAULT_MUTED_SETTING.value = mute - controllerState.controller.volume = if (mute) 0f else 1f + player.volume = if (mute) 0f else 1f }, onPictureInPictureClick = { - controllerState.controller.pause() + player.pause() PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity()) }, onZoomClick = onZoomClick?.let { { - controllerState.controller.pause() + player.pause() it() } }, - qualityButton = { - VideoQualityButton( - player = controllerState.controller, - controllerVisible = controllerVisible, - ) - }, + onOverflowQualityClick = { overflowQualityOpen.value = true }, modifier = modifier, accountViewModel = accountViewModel, ) + + if (overflowQualityOpen.value && videoGroup != null) { + VideoQualityPopup( + player = player, + videoGroup = videoGroup, + onDismiss = { overflowQualityOpen.value = false }, + ) + } } @OptIn(ExperimentalPermissionsApi::class) @Composable fun RenderTopButtons( mediaData: MediaItemData, + videoGroup: Tracks.Group?, + hasMultipleQualities: Boolean, + qualityButton: @Composable () -> Unit, controllerVisible: MutableState, startingMuteState: Boolean, isLive: Boolean, @@ -125,63 +196,156 @@ fun RenderTopButtons( onMuteClick: (Boolean) -> Unit, onPictureInPictureClick: () -> Unit, onZoomClick: (() -> Unit)?, + onOverflowQualityClick: () -> Unit, modifier: Modifier, accountViewModel: AccountViewModel, - qualityButton: @Composable () -> Unit = {}, ) { + val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle() val shareDialogVisible = remember { mutableStateOf(false) } val saveAction = rememberSaveMediaAction { context -> accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) } - Row(modifier) { - if (onZoomClick != null) { - FullScreenButton( - controllerVisible = controllerVisible, - onClick = onZoomClick, - ) + fun isAvailable(action: VideoPlayerAction): Boolean = + when (action) { + VideoPlayerAction.Fullscreen -> onZoomClick != null + VideoPlayerAction.Mute -> true + VideoPlayerAction.Quality -> hasMultipleQualities + VideoPlayerAction.Share -> true + VideoPlayerAction.Download -> !isLive + VideoPlayerAction.PictureInPicture -> pipSupported } - MuteButton( - controllerVisible = controllerVisible, - startingMuteState = startingMuteState, - toggle = onMuteClick, - ) + val topBarActions = buttonItems.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }.map { it.action } + val overflowActions = buttonItems.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }.map { it.action } - qualityButton() + Row(modifier) { + topBarActions.forEach { action -> + when (action) { + VideoPlayerAction.Fullscreen -> { + onZoomClick?.let { + FullScreenButton( + controllerVisible = controllerVisible, + onClick = it, + ) + } + } - Box { - AnimatedOverflowMenuButton( - controllerVisible = controllerVisible, - showShare = true, - showSave = !isLive, - showPip = pipSupported, - onShareClick = { shareDialogVisible.value = true }, - onSaveClick = saveAction, - onPipClick = onPictureInPictureClick, + VideoPlayerAction.Mute -> { + MuteButton( + controllerVisible = controllerVisible, + startingMuteState = startingMuteState, + toggle = onMuteClick, + ) + } + + VideoPlayerAction.Quality -> { + qualityButton() + } + + VideoPlayerAction.Share -> { + AnimatedTopBarIconButton( + controllerVisible = controllerVisible, + imageVector = Icons.Default.Share, + contentDescription = stringRes(R.string.share_or_save), + onClick = { shareDialogVisible.value = true }, + ) + } + + VideoPlayerAction.Download -> { + AnimatedTopBarIconButton( + controllerVisible = controllerVisible, + imageVector = Icons.Default.SaveAlt, + contentDescription = stringRes(R.string.download_to_phone), + onClick = saveAction, + ) + } + + VideoPlayerAction.PictureInPicture -> { + AnimatedTopBarIconButton( + controllerVisible = controllerVisible, + imageVector = Icons.Default.PictureInPicture, + contentDescription = stringRes(R.string.picture_in_picture), + onClick = onPictureInPictureClick, + ) + } + } + } + + if (overflowActions.isNotEmpty()) { + Box { + AnimatedOverflowMenuButton( + controllerVisible = controllerVisible, + actions = overflowActions, + onFullscreenClick = onZoomClick, + onMuteClick = { onMuteClick(!startingMuteState) }, + startingMuteState = startingMuteState, + onQualityClick = onOverflowQualityClick, + onShareClick = { shareDialogVisible.value = true }, + onSaveClick = saveAction, + onPipClick = onPictureInPictureClick, + ) + } + } + + if (shareDialogVisible.value) { + ShareMediaAction( + popupExpanded = shareDialogVisible, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = { shareDialogVisible.value = false }, + content = + MediaUrlVideo( + url = mediaData.videoUri, + mimeType = mediaData.mimeType, + artworkUri = mediaData.artworkUri, + authorName = mediaData.authorName, + description = mediaData.title, + uri = mediaData.callbackUri, + ), + accountViewModel = accountViewModel, + ) + } + } +} + +@Composable +internal fun AnimatedTopBarIconButton( + controllerVisible: State, + imageVector: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.7f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), ) - if (shareDialogVisible.value) { - ShareMediaAction( - popupExpanded = shareDialogVisible, - videoUri = mediaData.videoUri, - postNostrUri = mediaData.callbackUri, - blurhash = null, - dim = null, - hash = null, - mimeType = mediaData.mimeType, - onDismiss = { shareDialogVisible.value = false }, - content = - MediaUrlVideo( - url = mediaData.videoUri, - mimeType = mediaData.mimeType, - artworkUri = mediaData.artworkUri, - authorName = mediaData.authorName, - description = mediaData.title, - uri = mediaData.callbackUri, - ), - accountViewModel = accountViewModel, + IconButton( + onClick = onClick, + modifier = Size50Modifier, + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size20Modifier, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt index 6a3fb5539..cc2942b04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -118,40 +118,53 @@ fun VideoQualityButton( } if (openDialog) { - // Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every - // rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open. - var videoSize by remember(player) { mutableStateOf(player.videoSize) } - DisposableEffect(player) { - val listener = - object : Player.Listener { - override fun onVideoSizeChanged(newSize: VideoSize) { - videoSize = newSize - } - } - player.addListener(listener) - onDispose { player.removeListener(listener) } - } - val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } + VideoQualityPopup( + player = player, + videoGroup = videoGroup, + onDismiss = { openDialog = false }, + ) + } +} - Popup( - alignment = Alignment.BottomCenter, - onDismissRequest = { openDialog = false }, - properties = PopupProperties(focusable = true), - ) { - VideoQualityChoices( - videoGroup = videoGroup, - currentShortSide = currentShortSide, - isAuto = !hasVideoOverride(player), - onSelectAuto = { - if (hasVideoOverride(player)) clearVideoOverride(player) - openDialog = false - }, - onSelectTrack = { trackIndex -> - selectVideoTrack(player, videoGroup, trackIndex) - openDialog = false - }, - ) - } +@Composable +fun VideoQualityPopup( + player: Player, + videoGroup: Tracks.Group, + onDismiss: () -> Unit, +) { + // Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every + // rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open. + var videoSize by remember(player) { mutableStateOf(player.videoSize) } + DisposableEffect(player) { + val listener = + object : Player.Listener { + override fun onVideoSizeChanged(newSize: VideoSize) { + videoSize = newSize + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } + + Popup( + alignment = Alignment.BottomCenter, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + VideoQualityChoices( + videoGroup = videoGroup, + currentShortSide = currentShortSide, + isAuto = !hasVideoOverride(player), + onSelectAuto = { + if (hasVideoOverride(player)) clearVideoOverride(player) + onDismiss() + }, + onSelectTrack = { trackIndex -> + selectVideoTrack(player, videoGroup, trackIndex) + onDismiss() + }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a1e79eb3f..6dec596f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -163,6 +163,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen @@ -305,6 +306,7 @@ fun BuildNavigation( composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } + composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f94f5ec5b..c93cddace 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -213,6 +213,8 @@ sealed class Route { @Serializable object BottomBarSettings : Route() + @Serializable object VideoPlayerSettings : Route() + @Serializable object CallSettings : Route() @Serializable object Lists : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c47d64d38..90c454594 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1191,6 +1191,13 @@ class AccountViewModel( account.changeReactionRowItems(items) } + fun videoPlayerButtonItemsFlow() = account.settings.syncedSettings.videoPlayer.buttonItems + + fun changeVideoPlayerButtonItems(items: List) = + launchSigner { + account.changeVideoPlayerButtonItems(items) + } + fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 072c26c95..925643622 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material.icons.outlined.VideoSettings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -217,6 +218,13 @@ fun AllSettingsScreen( tint = tint, onClick = { nav.nav(Route.BottomBarSettings) }, ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.video_player_settings, + icon = Icons.Outlined.VideoSettings, + tint = tint, + onClick = { nav.nav(Route.VideoPlayerSettings) }, + ) HorizontalDivider(thickness = 4.dp) SettingsSectionHeader(R.string.danger_zone) accountViewModel.account.settings.keyPair.privKey?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt new file mode 100644 index 000000000..6771bc235 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt @@ -0,0 +1,345 @@ +/* + * 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.screen.loggedIn.settings + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +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.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.VideoButtonLocation +import com.vitorpamplona.amethyst.model.VideoPlayerAction +import com.vitorpamplona.amethyst.model.VideoPlayerButtonItem +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +@Composable +@Preview(device = "spec:width=2100px,height=2340px,dpi=440") +fun VideoPlayerSettingsScreenPreview() { + ThemeComparisonRow { + VideoPlayerSettingsScreen( + mockAccountViewModel(), + EmptyNav(), + ) + } +} + +@Composable +fun VideoPlayerSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.video_player_settings), nav::popBack) + }, + ) { padding -> + Column(Modifier.padding(padding)) { + VideoPlayerSettingsContent(accountViewModel) + } + } +} + +@Composable +fun VideoPlayerSettingsContent(accountViewModel: AccountViewModel) { + val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle() + var items by remember(buttonItems) { mutableStateOf(buttonItems.toList()) } + + fun save(newItems: List) { + items = newItems.toMutableList() + accountViewModel.changeVideoPlayerButtonItems(newItems) + } + + var draggedItemIndex by remember { mutableIntStateOf(-1) } + var dragOffset by remember { mutableFloatStateOf(0f) } + val itemHeights = remember { mutableStateMapOf() } + val isDragging = draggedItemIndex >= 0 + val scrollState = remember { ScrollState(0) } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState, enabled = !isDragging), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringRes(R.string.video_player_settings_description), + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp), + ) + + items.forEachIndexed { index, item -> + val isItemDragging = draggedItemIndex == index + val targetElevation = if (isItemDragging) 8f else 0f + val animatedElevation by animateFloatAsState( + targetValue = targetElevation, + label = "dragElevation", + ) + + VideoPlayerButtonItemCard( + item = item, + isDragging = isItemDragging, + dragOffsetY = if (isItemDragging) dragOffset else 0f, + elevation = animatedElevation, + onSelectLocation = { newLocation -> + if (item.location != newLocation) { + val newItems = items.toMutableList() + newItems[index] = item.copy(location = newLocation) + save(newItems) + } + }, + onMeasured = { height -> + itemHeights[index] = height + }, + onDragStart = { + draggedItemIndex = index + dragOffset = 0f + }, + onDrag = { dragAmount -> + dragOffset += dragAmount + + val currentIndex = draggedItemIndex + if (currentIndex < 0) return@VideoPlayerButtonItemCard + + if (dragOffset < 0 && currentIndex > 0) { + val aboveHeight = itemHeights[currentIndex - 1] ?: 0f + if (-dragOffset > aboveHeight / 2f) { + val newItems = items.toMutableList() + val temp = newItems[currentIndex - 1] + newItems[currentIndex - 1] = newItems[currentIndex] + newItems[currentIndex] = temp + items = newItems + + val h1 = itemHeights[currentIndex] + val h2 = itemHeights[currentIndex - 1] + if (h1 != null) itemHeights[currentIndex - 1] = h1 + if (h2 != null) itemHeights[currentIndex] = h2 + + dragOffset += aboveHeight + draggedItemIndex = currentIndex - 1 + } + } + + if (dragOffset > 0 && currentIndex < items.lastIndex) { + val belowHeight = itemHeights[currentIndex + 1] ?: 0f + if (dragOffset > belowHeight / 2f) { + val newItems = items.toMutableList() + val temp = newItems[currentIndex + 1] + newItems[currentIndex + 1] = newItems[currentIndex] + newItems[currentIndex] = temp + items = newItems + + val h1 = itemHeights[currentIndex] + val h2 = itemHeights[currentIndex + 1] + if (h1 != null) itemHeights[currentIndex + 1] = h1 + if (h2 != null) itemHeights[currentIndex] = h2 + + dragOffset -= belowHeight + draggedItemIndex = currentIndex + 1 + } + } + }, + onDragEnd = { + draggedItemIndex = -1 + dragOffset = 0f + save(items) + }, + onDragCancel = { + draggedItemIndex = -1 + dragOffset = 0f + }, + modifier = + Modifier + .zIndex(if (isItemDragging) 1f else 0f), + ) + if (index < items.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun VideoPlayerButtonItemCard( + item: VideoPlayerButtonItem, + isDragging: Boolean, + dragOffsetY: Float, + elevation: Float, + onSelectLocation: (VideoButtonLocation) -> Unit, + onMeasured: (Float) -> Unit, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val actionName = videoPlayerActionName(item.action) + val actionDescription = videoPlayerActionDescription(item.action) + + Column( + modifier = + modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + onMeasured(coordinates.size.height.toFloat()) + }.graphicsLayer { + translationY = dragOffsetY + shadowElevation = elevation + if (isDragging) { + scaleX = 1.02f + scaleY = 1.02f + } + }.padding(vertical = 8.dp, horizontal = Size20dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = actionName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = actionDescription, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Box( + modifier = Modifier.size(32.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + Icons.Default.DragIndicator, + contentDescription = stringRes(R.string.video_player_settings_reorder), + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = item.location == VideoButtonLocation.TopBar, + onClick = { onSelectLocation(VideoButtonLocation.TopBar) }, + label = { Text(stringRes(R.string.video_player_settings_location_top_bar)) }, + ) + FilterChip( + selected = item.location == VideoButtonLocation.OverflowMenu, + onClick = { onSelectLocation(VideoButtonLocation.OverflowMenu) }, + label = { Text(stringRes(R.string.video_player_settings_location_overflow)) }, + ) + } + } +} + +@Composable +fun videoPlayerActionName(action: VideoPlayerAction): String = + when (action) { + VideoPlayerAction.Fullscreen -> stringRes(R.string.video_player_settings_action_fullscreen) + VideoPlayerAction.Mute -> stringRes(R.string.video_player_settings_action_mute) + VideoPlayerAction.Quality -> stringRes(R.string.video_player_settings_action_quality) + VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share) + VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download) + VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip) + } + +@Composable +fun videoPlayerActionDescription(action: VideoPlayerAction): String = + when (action) { + VideoPlayerAction.Fullscreen -> stringRes(R.string.video_player_settings_action_fullscreen_description) + VideoPlayerAction.Mute -> stringRes(R.string.video_player_settings_action_mute_description) + VideoPlayerAction.Quality -> stringRes(R.string.video_player_settings_action_quality_description) + VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share_description) + VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download_description) + VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip_description) + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ac4486157..d9eb8d736 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1595,6 +1595,24 @@ Pay Send a payment to the author via their payment targets + Video Player Buttons + Choose which buttons appear on the video player and which live in the overflow menu. Drag to reorder. + Reorder + Top Bar + Overflow Menu + Fullscreen + Open the video in a fullscreen zoom view + Mute + Toggle audio on or off + Video Quality + Pick a resolution for HLS videos (hidden on non-HLS videos) + Share or Save + Share the video link externally + Save to Phone + Download the video to your device (hidden on live streams) + Picture-in-Picture + Play the video in a floating window (hidden if unsupported) + Profile Picture of %1$s Relay %1$s Expand relay list