Merge pull request #2528 from davotoula/configurable-video-buttons
Configurable video buttons
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+217
-47
@@ -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,8 @@ fun RenderTopButtonsPreview() {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
RenderTopButtons(
|
||||
mediaData = MediaItemData("http://test.mp4"),
|
||||
hasMultipleQualities = false,
|
||||
qualityButton = {},
|
||||
controllerVisible = remember { mutableStateOf(true) },
|
||||
startingMuteState = false,
|
||||
isLive = false,
|
||||
@@ -59,6 +90,7 @@ fun RenderTopButtonsPreview() {
|
||||
onMuteClick = {},
|
||||
onPictureInPictureClick = {},
|
||||
onZoomClick = {},
|
||||
onOverflowQualityClick = {},
|
||||
modifier = Modifier,
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
)
|
||||
@@ -82,42 +114,78 @@ 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,
|
||||
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,
|
||||
hasMultipleQualities: Boolean,
|
||||
qualityButton: @Composable () -> Unit,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
isLive: Boolean,
|
||||
@@ -125,63 +193,165 @@ 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 canFullscreen = onZoomClick != null
|
||||
val topBarActions =
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
|
||||
buttonItems
|
||||
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
|
||||
.map { it.action }
|
||||
}
|
||||
val overflowActions =
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
|
||||
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 {
|
||||
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()) {
|
||||
AnimatedOverflowMenuButton(
|
||||
controllerVisible = controllerVisible,
|
||||
showShare = true,
|
||||
showSave = !isLive,
|
||||
showPip = pipSupported,
|
||||
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,
|
||||
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),
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
+1
@@ -217,6 +217,7 @@ private fun hasNonDefaultFilters(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
@Composable
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) {
|
||||
val currentScope by searchBarViewModel.scope.collectAsStateWithLifecycle()
|
||||
val currentSource by searchBarViewModel.source.collectAsStateWithLifecycle()
|
||||
|
||||
+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
|
||||
@@ -146,6 +147,13 @@ fun AllSettingsScreen(
|
||||
onClick = { nav.nav(Route.UpdateReactionType) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.video_player_settings,
|
||||
icon = Icons.Outlined.VideoSettings,
|
||||
tint = tint,
|
||||
onClick = { nav.nav(Route.VideoPlayerSettings) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.zaps,
|
||||
icon = Icons.Outlined.Bolt,
|
||||
|
||||
+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