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