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.
This commit is contained in:
@@ -554,6 +554,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun changeVideoPlayerButtonItems(items: List<VideoPlayerButtonItem>) {
|
||||
if (settings.changeVideoPlayerButtonItems(items)) {
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
selectedZapType: LnZapEvent.ZapType,
|
||||
|
||||
@@ -267,6 +267,15 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeVideoPlayerButtonItems(newItems: List<VideoPlayerButtonItem>): 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
|
||||
|
||||
@@ -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<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -143,6 +153,11 @@ class AccountReactionPreferences(
|
||||
var reactionRowItems: MutableStateFlow<ImmutableList<ReactionRowItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class AccountVideoPlayerPreferences(
|
||||
var buttonItems: MutableStateFlow<ImmutableList<VideoPlayerButtonItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class AccountZapPreferences(
|
||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
|
||||
|
||||
+38
@@ -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<String> {
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
val codedList = mutableSetOf<String>()
|
||||
@@ -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<VideoPlayerButtonItem> = DefaultVideoPlayerButtonItems,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
+89
-38
@@ -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<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
showShare: Boolean = true,
|
||||
showSave: Boolean = true,
|
||||
showPip: Boolean = false,
|
||||
actions: List<VideoPlayerAction>,
|
||||
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<VideoPlayerAction>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+216
-52
@@ -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<Boolean>,
|
||||
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<Boolean>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+46
-33
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||
|
||||
@@ -213,6 +213,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object BottomBarSettings : Route()
|
||||
|
||||
@Serializable object VideoPlayerSettings : Route()
|
||||
|
||||
@Serializable object CallSettings : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
+7
@@ -1191,6 +1191,13 @@ class AccountViewModel(
|
||||
account.changeReactionRowItems(items)
|
||||
}
|
||||
|
||||
fun videoPlayerButtonItemsFlow() = account.settings.syncedSettings.videoPlayer.buttonItems
|
||||
|
||||
fun changeVideoPlayerButtonItems(items: List<com.vitorpamplona.amethyst.model.VideoPlayerButtonItem>) =
|
||||
launchSigner {
|
||||
account.changeVideoPlayerButtonItems(items)
|
||||
}
|
||||
|
||||
fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
selectedZapType: LnZapEvent.ZapType,
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
+345
@@ -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<VideoPlayerButtonItem>) {
|
||||
items = newItems.toMutableList()
|
||||
accountViewModel.changeVideoPlayerButtonItems(newItems)
|
||||
}
|
||||
|
||||
var draggedItemIndex by remember { mutableIntStateOf(-1) }
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
val itemHeights = remember { mutableStateMapOf<Int, Float>() }
|
||||
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)
|
||||
}
|
||||
@@ -1595,6 +1595,24 @@
|
||||
<string name="reactions_settings_pay">Pay</string>
|
||||
<string name="reactions_settings_pay_description">Send a payment to the author via their payment targets</string>
|
||||
|
||||
<string name="video_player_settings">Video Player Buttons</string>
|
||||
<string name="video_player_settings_description">Choose which buttons appear on the video player and which live in the overflow menu. Drag to reorder.</string>
|
||||
<string name="video_player_settings_reorder">Reorder</string>
|
||||
<string name="video_player_settings_location_top_bar">Top Bar</string>
|
||||
<string name="video_player_settings_location_overflow">Overflow Menu</string>
|
||||
<string name="video_player_settings_action_fullscreen">Fullscreen</string>
|
||||
<string name="video_player_settings_action_fullscreen_description">Open the video in a fullscreen zoom view</string>
|
||||
<string name="video_player_settings_action_mute">Mute</string>
|
||||
<string name="video_player_settings_action_mute_description">Toggle audio on or off</string>
|
||||
<string name="video_player_settings_action_quality">Video Quality</string>
|
||||
<string name="video_player_settings_action_quality_description">Pick a resolution for HLS videos (hidden on non-HLS videos)</string>
|
||||
<string name="video_player_settings_action_share">Share or Save</string>
|
||||
<string name="video_player_settings_action_share_description">Share the video link externally</string>
|
||||
<string name="video_player_settings_action_download">Save to Phone</string>
|
||||
<string name="video_player_settings_action_download_description">Download the video to your device (hidden on live streams)</string>
|
||||
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
|
||||
<string name="video_player_settings_action_pip_description">Play the video in a floating window (hidden if unsupported)</string>
|
||||
|
||||
<string name="profile_image_of_user">Profile Picture of %1$s</string>
|
||||
<string name="relay_info">Relay %1$s</string>
|
||||
<string name="expand_relay_list">Expand relay list</string>
|
||||
|
||||
Reference in New Issue
Block a user