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(
|
suspend fun updateZapAmounts(
|
||||||
amountSet: List<Long>,
|
amountSet: List<Long>,
|
||||||
selectedZapType: LnZapEvent.ZapType,
|
selectedZapType: LnZapEvent.ZapType,
|
||||||
|
|||||||
@@ -267,6 +267,15 @@ class AccountSettings(
|
|||||||
return false
|
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? {
|
fun defaultNwcWallet(): NwcWalletEntryNorm? {
|
||||||
val id = defaultNwcWalletId.value
|
val id = defaultNwcWalletId.value
|
||||||
val wallets = nwcWallets.value
|
val wallets = nwcWallets.value
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class AccountSyncedSettings(
|
|||||||
MutableStateFlow(internalSettings.security.maxHashtagLimit),
|
MutableStateFlow(internalSettings.security.maxHashtagLimit),
|
||||||
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
|
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
|
||||||
)
|
)
|
||||||
|
val videoPlayer =
|
||||||
|
AccountVideoPlayerPreferences(
|
||||||
|
MutableStateFlow(internalSettings.videoPlayer.buttonItems.toImmutableList()),
|
||||||
|
)
|
||||||
|
|
||||||
fun toInternal(): AccountSyncedSettingsInternal =
|
fun toInternal(): AccountSyncedSettingsInternal =
|
||||||
AccountSyncedSettingsInternal(
|
AccountSyncedSettingsInternal(
|
||||||
@@ -79,6 +83,7 @@ class AccountSyncedSettings(
|
|||||||
security.maxHashtagLimit.value,
|
security.maxHashtagLimit.value,
|
||||||
security.sendKind0EventsToLocalRelay.value,
|
security.sendKind0EventsToLocalRelay.value,
|
||||||
),
|
),
|
||||||
|
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||||
@@ -132,6 +137,11 @@ class AccountSyncedSettings(
|
|||||||
if (security.sendKind0EventsToLocalRelay.value != syncedSettingsInternal.security.sendKind0EventsToLocalRelay) {
|
if (security.sendKind0EventsToLocalRelay.value != syncedSettingsInternal.security.sendKind0EventsToLocalRelay) {
|
||||||
security.sendKind0EventsToLocalRelay.tryEmit(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()
|
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||||
@@ -143,6 +153,11 @@ class AccountReactionPreferences(
|
|||||||
var reactionRowItems: MutableStateFlow<ImmutableList<ReactionRowItem>>,
|
var reactionRowItems: MutableStateFlow<ImmutableList<ReactionRowItem>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class AccountVideoPlayerPreferences(
|
||||||
|
var buttonItems: MutableStateFlow<ImmutableList<VideoPlayerButtonItem>>,
|
||||||
|
)
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class AccountZapPreferences(
|
class AccountZapPreferences(
|
||||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
|
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
|
||||||
|
|||||||
+38
@@ -66,6 +66,38 @@ val DefaultReactionRowItems =
|
|||||||
ReactionRowItem(ReactionRowAction.Pay, showCounter = false),
|
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> {
|
fun getLanguagesSpokenByUser(): Set<String> {
|
||||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||||
val codedList = mutableSetOf<String>()
|
val codedList = mutableSetOf<String>()
|
||||||
@@ -81,6 +113,12 @@ class AccountSyncedSettingsInternal(
|
|||||||
val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(),
|
val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(),
|
||||||
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
|
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
|
||||||
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
|
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
|
||||||
|
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class AccountVideoPlayerPreferencesInternal(
|
||||||
|
var buttonItems: List<VideoPlayerButtonItem> = DefaultVideoPlayerButtonItems,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
+89
-38
@@ -28,10 +28,14 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.MoreVert
|
||||||
import androidx.compose.material.icons.filled.PictureInPicture
|
import androidx.compose.material.icons.filled.PictureInPicture
|
||||||
import androidx.compose.material.icons.filled.SaveAlt
|
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.Share
|
||||||
|
import androidx.compose.material.icons.filled.ZoomOutMap
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -44,6 +48,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.VideoPlayerAction
|
||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||||
@@ -63,9 +68,11 @@ fun OverflowMenuButtonPreview() {
|
|||||||
ThemeComparisonColumn {
|
ThemeComparisonColumn {
|
||||||
Box(Modifier.background(BitcoinOrange)) {
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
OverflowMenuButton(
|
OverflowMenuButton(
|
||||||
showShare = true,
|
actions = listOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture),
|
||||||
showSave = true,
|
startingMuteState = false,
|
||||||
showPip = true,
|
onFullscreenClick = {},
|
||||||
|
onMuteClick = {},
|
||||||
|
onQualityClick = {},
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
onSaveClick = {},
|
onSaveClick = {},
|
||||||
onPipClick = {},
|
onPipClick = {},
|
||||||
@@ -77,13 +84,15 @@ fun OverflowMenuButtonPreview() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AnimatedOverflowMenuButton(
|
fun AnimatedOverflowMenuButton(
|
||||||
controllerVisible: State<Boolean>,
|
controllerVisible: State<Boolean>,
|
||||||
modifier: Modifier = Modifier,
|
actions: List<VideoPlayerAction>,
|
||||||
showShare: Boolean = true,
|
startingMuteState: Boolean,
|
||||||
showSave: Boolean = true,
|
onFullscreenClick: (() -> Unit)?,
|
||||||
showPip: Boolean = false,
|
onMuteClick: () -> Unit,
|
||||||
|
onQualityClick: () -> Unit,
|
||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
onSaveClick: () -> Unit,
|
onSaveClick: () -> Unit,
|
||||||
onPipClick: () -> Unit,
|
onPipClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = controllerVisible.value,
|
visible = controllerVisible.value,
|
||||||
@@ -92,9 +101,11 @@ fun AnimatedOverflowMenuButton(
|
|||||||
exit = FadeOut,
|
exit = FadeOut,
|
||||||
) {
|
) {
|
||||||
OverflowMenuButton(
|
OverflowMenuButton(
|
||||||
showShare = showShare,
|
actions = actions,
|
||||||
showSave = showSave,
|
startingMuteState = startingMuteState,
|
||||||
showPip = showPip,
|
onFullscreenClick = onFullscreenClick,
|
||||||
|
onMuteClick = onMuteClick,
|
||||||
|
onQualityClick = onQualityClick,
|
||||||
onShareClick = onShareClick,
|
onShareClick = onShareClick,
|
||||||
onSaveClick = onSaveClick,
|
onSaveClick = onSaveClick,
|
||||||
onPipClick = onPipClick,
|
onPipClick = onPipClick,
|
||||||
@@ -104,9 +115,11 @@ fun AnimatedOverflowMenuButton(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OverflowMenuButton(
|
fun OverflowMenuButton(
|
||||||
showShare: Boolean,
|
actions: List<VideoPlayerAction>,
|
||||||
showSave: Boolean,
|
startingMuteState: Boolean,
|
||||||
showPip: Boolean,
|
onFullscreenClick: (() -> Unit)?,
|
||||||
|
onMuteClick: () -> Unit,
|
||||||
|
onQualityClick: () -> Unit,
|
||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
onSaveClick: () -> Unit,
|
onSaveClick: () -> Unit,
|
||||||
onPipClick: () -> Unit,
|
onPipClick: () -> Unit,
|
||||||
@@ -141,31 +154,69 @@ fun OverflowMenuButton(
|
|||||||
onDismiss = { menuExpanded.value = false },
|
onDismiss = { menuExpanded.value = false },
|
||||||
) {
|
) {
|
||||||
M3ActionSection {
|
M3ActionSection {
|
||||||
if (showShare) {
|
actions.forEach { action ->
|
||||||
M3ActionRow(
|
when (action) {
|
||||||
icon = Icons.Default.Share,
|
VideoPlayerAction.Fullscreen -> {
|
||||||
text = stringRes(R.string.share_or_save),
|
onFullscreenClick?.let { zoom ->
|
||||||
) {
|
M3ActionRow(
|
||||||
menuExpanded.value = false
|
icon = Icons.Default.ZoomOutMap,
|
||||||
onShareClick()
|
text = stringRes(R.string.video_player_settings_action_fullscreen),
|
||||||
}
|
) {
|
||||||
}
|
menuExpanded.value = false
|
||||||
if (showSave) {
|
zoom()
|
||||||
M3ActionRow(
|
}
|
||||||
icon = Icons.Default.SaveAlt,
|
}
|
||||||
text = stringRes(R.string.download_to_phone),
|
}
|
||||||
) {
|
|
||||||
menuExpanded.value = false
|
VideoPlayerAction.Mute -> {
|
||||||
onSaveClick()
|
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),
|
||||||
if (showPip) {
|
) {
|
||||||
M3ActionRow(
|
menuExpanded.value = false
|
||||||
icon = Icons.Default.PictureInPicture,
|
onMuteClick()
|
||||||
text = stringRes(R.string.picture_in_picture),
|
}
|
||||||
) {
|
}
|
||||||
menuExpanded.value = false
|
|
||||||
onPipClick()
|
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
|
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
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.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
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.DEFAULT_MUTED_SETTING
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
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.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
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.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
|
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@@ -52,6 +81,8 @@ fun RenderTopButtonsPreview() {
|
|||||||
Box(Modifier.background(BitcoinOrange)) {
|
Box(Modifier.background(BitcoinOrange)) {
|
||||||
RenderTopButtons(
|
RenderTopButtons(
|
||||||
mediaData = MediaItemData("http://test.mp4"),
|
mediaData = MediaItemData("http://test.mp4"),
|
||||||
|
hasMultipleQualities = false,
|
||||||
|
qualityButton = {},
|
||||||
controllerVisible = remember { mutableStateOf(true) },
|
controllerVisible = remember { mutableStateOf(true) },
|
||||||
startingMuteState = false,
|
startingMuteState = false,
|
||||||
isLive = false,
|
isLive = false,
|
||||||
@@ -59,6 +90,7 @@ fun RenderTopButtonsPreview() {
|
|||||||
onMuteClick = {},
|
onMuteClick = {},
|
||||||
onPictureInPictureClick = {},
|
onPictureInPictureClick = {},
|
||||||
onZoomClick = {},
|
onZoomClick = {},
|
||||||
|
onOverflowQualityClick = {},
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
accountViewModel = mockAccountViewModel(),
|
accountViewModel = mockAccountViewModel(),
|
||||||
)
|
)
|
||||||
@@ -82,42 +114,78 @@ fun RenderTopButtons(
|
|||||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
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(
|
RenderTopButtons(
|
||||||
mediaData = mediaData,
|
mediaData = mediaData,
|
||||||
|
hasMultipleQualities = hasMultipleQualities,
|
||||||
|
qualityButton = {
|
||||||
|
VideoQualityButton(
|
||||||
|
player = player,
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
)
|
||||||
|
},
|
||||||
controllerVisible = controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
startingMuteState = controllerState.controller.volume < 0.001,
|
startingMuteState = isMuted,
|
||||||
isLive = isLive,
|
isLive = isLive,
|
||||||
pipSupported = pipSupported,
|
pipSupported = pipSupported,
|
||||||
onMuteClick = { mute ->
|
onMuteClick = { mute ->
|
||||||
DEFAULT_MUTED_SETTING.value = mute
|
DEFAULT_MUTED_SETTING.value = mute
|
||||||
controllerState.controller.volume = if (mute) 0f else 1f
|
player.volume = if (mute) 0f else 1f
|
||||||
},
|
},
|
||||||
onPictureInPictureClick = {
|
onPictureInPictureClick = {
|
||||||
controllerState.controller.pause()
|
player.pause()
|
||||||
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity())
|
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity())
|
||||||
},
|
},
|
||||||
onZoomClick =
|
onZoomClick =
|
||||||
onZoomClick?.let {
|
onZoomClick?.let {
|
||||||
{
|
{
|
||||||
controllerState.controller.pause()
|
player.pause()
|
||||||
it()
|
it()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
qualityButton = {
|
onOverflowQualityClick = { overflowQualityOpen.value = true },
|
||||||
VideoQualityButton(
|
|
||||||
player = controllerState.controller,
|
|
||||||
controllerVisible = controllerVisible,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (overflowQualityOpen.value && videoGroup != null) {
|
||||||
|
VideoQualityPopup(
|
||||||
|
player = player,
|
||||||
|
videoGroup = videoGroup,
|
||||||
|
onDismiss = { overflowQualityOpen.value = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderTopButtons(
|
fun RenderTopButtons(
|
||||||
mediaData: MediaItemData,
|
mediaData: MediaItemData,
|
||||||
|
hasMultipleQualities: Boolean,
|
||||||
|
qualityButton: @Composable () -> Unit,
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
startingMuteState: Boolean,
|
startingMuteState: Boolean,
|
||||||
isLive: Boolean,
|
isLive: Boolean,
|
||||||
@@ -125,63 +193,165 @@ fun RenderTopButtons(
|
|||||||
onMuteClick: (Boolean) -> Unit,
|
onMuteClick: (Boolean) -> Unit,
|
||||||
onPictureInPictureClick: () -> Unit,
|
onPictureInPictureClick: () -> Unit,
|
||||||
onZoomClick: (() -> Unit)?,
|
onZoomClick: (() -> Unit)?,
|
||||||
|
onOverflowQualityClick: () -> Unit,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
qualityButton: @Composable () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
|
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
|
||||||
val shareDialogVisible = remember { mutableStateOf(false) }
|
val shareDialogVisible = remember { mutableStateOf(false) }
|
||||||
val saveAction =
|
val saveAction =
|
||||||
rememberSaveMediaAction { context ->
|
rememberSaveMediaAction { context ->
|
||||||
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(modifier) {
|
fun isAvailable(action: VideoPlayerAction): Boolean =
|
||||||
if (onZoomClick != null) {
|
when (action) {
|
||||||
FullScreenButton(
|
VideoPlayerAction.Fullscreen -> onZoomClick != null
|
||||||
controllerVisible = controllerVisible,
|
VideoPlayerAction.Mute -> true
|
||||||
onClick = onZoomClick,
|
VideoPlayerAction.Quality -> hasMultipleQualities
|
||||||
)
|
VideoPlayerAction.Share -> true
|
||||||
|
VideoPlayerAction.Download -> !isLive
|
||||||
|
VideoPlayerAction.PictureInPicture -> pipSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
MuteButton(
|
val canFullscreen = onZoomClick != null
|
||||||
controllerVisible = controllerVisible,
|
val topBarActions =
|
||||||
startingMuteState = startingMuteState,
|
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
|
||||||
toggle = onMuteClick,
|
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(
|
AnimatedOverflowMenuButton(
|
||||||
controllerVisible = controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
showShare = true,
|
actions = overflowActions,
|
||||||
showSave = !isLive,
|
onFullscreenClick = onZoomClick,
|
||||||
showPip = pipSupported,
|
onMuteClick = { onMuteClick(!startingMuteState) },
|
||||||
|
startingMuteState = startingMuteState,
|
||||||
|
onQualityClick = onOverflowQualityClick,
|
||||||
onShareClick = { shareDialogVisible.value = true },
|
onShareClick = { shareDialogVisible.value = true },
|
||||||
onSaveClick = saveAction,
|
onSaveClick = saveAction,
|
||||||
onPipClick = onPictureInPictureClick,
|
onPipClick = onPictureInPictureClick,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (shareDialogVisible.value) {
|
if (shareDialogVisible.value) {
|
||||||
ShareMediaAction(
|
ShareMediaAction(
|
||||||
popupExpanded = shareDialogVisible,
|
popupExpanded = shareDialogVisible,
|
||||||
videoUri = mediaData.videoUri,
|
videoUri = mediaData.videoUri,
|
||||||
postNostrUri = mediaData.callbackUri,
|
postNostrUri = mediaData.callbackUri,
|
||||||
blurhash = null,
|
blurhash = null,
|
||||||
dim = null,
|
dim = null,
|
||||||
hash = null,
|
hash = null,
|
||||||
mimeType = mediaData.mimeType,
|
mimeType = mediaData.mimeType,
|
||||||
onDismiss = { shareDialogVisible.value = false },
|
onDismiss = { shareDialogVisible.value = false },
|
||||||
content =
|
content =
|
||||||
MediaUrlVideo(
|
MediaUrlVideo(
|
||||||
url = mediaData.videoUri,
|
url = mediaData.videoUri,
|
||||||
mimeType = mediaData.mimeType,
|
mimeType = mediaData.mimeType,
|
||||||
artworkUri = mediaData.artworkUri,
|
artworkUri = mediaData.artworkUri,
|
||||||
authorName = mediaData.authorName,
|
authorName = mediaData.authorName,
|
||||||
description = mediaData.title,
|
description = mediaData.title,
|
||||||
uri = mediaData.callbackUri,
|
uri = mediaData.callbackUri,
|
||||||
),
|
),
|
||||||
accountViewModel = accountViewModel,
|
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) {
|
if (openDialog) {
|
||||||
// Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every
|
VideoQualityPopup(
|
||||||
// rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open.
|
player = player,
|
||||||
var videoSize by remember(player) { mutableStateOf(player.videoSize) }
|
videoGroup = videoGroup,
|
||||||
DisposableEffect(player) {
|
onDismiss = { openDialog = false },
|
||||||
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(
|
@Composable
|
||||||
alignment = Alignment.BottomCenter,
|
fun VideoQualityPopup(
|
||||||
onDismissRequest = { openDialog = false },
|
player: Player,
|
||||||
properties = PopupProperties(focusable = true),
|
videoGroup: Tracks.Group,
|
||||||
) {
|
onDismiss: () -> Unit,
|
||||||
VideoQualityChoices(
|
) {
|
||||||
videoGroup = videoGroup,
|
// Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every
|
||||||
currentShortSide = currentShortSide,
|
// rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open.
|
||||||
isAuto = !hasVideoOverride(player),
|
var videoSize by remember(player) { mutableStateOf(player.videoSize) }
|
||||||
onSelectAuto = {
|
DisposableEffect(player) {
|
||||||
if (hasVideoOverride(player)) clearVideoOverride(player)
|
val listener =
|
||||||
openDialog = false
|
object : Player.Listener {
|
||||||
},
|
override fun onVideoSizeChanged(newSize: VideoSize) {
|
||||||
onSelectTrack = { trackIndex ->
|
videoSize = newSize
|
||||||
selectVideoTrack(player, videoGroup, trackIndex)
|
}
|
||||||
openDialog = false
|
}
|
||||||
},
|
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.SettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen
|
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.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.shorts.ShortsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
||||||
@@ -305,6 +306,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object BottomBarSettings : Route()
|
@Serializable object BottomBarSettings : Route()
|
||||||
|
|
||||||
|
@Serializable object VideoPlayerSettings : Route()
|
||||||
|
|
||||||
@Serializable object CallSettings : Route()
|
@Serializable object CallSettings : Route()
|
||||||
|
|
||||||
@Serializable object Lists : Route()
|
@Serializable object Lists : Route()
|
||||||
|
|||||||
+7
@@ -1191,6 +1191,13 @@ class AccountViewModel(
|
|||||||
account.changeReactionRowItems(items)
|
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(
|
fun updateZapAmounts(
|
||||||
amountSet: List<Long>,
|
amountSet: List<Long>,
|
||||||
selectedZapType: LnZapEvent.ZapType,
|
selectedZapType: LnZapEvent.ZapType,
|
||||||
|
|||||||
+1
@@ -217,6 +217,7 @@ private fun hasNonDefaultFilters(
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Suppress("AssignedValueIsNeverRead")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
@Composable
|
@Composable
|
||||||
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) {
|
private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) {
|
||||||
val currentScope by searchBarViewModel.scope.collectAsStateWithLifecycle()
|
val currentScope by searchBarViewModel.scope.collectAsStateWithLifecycle()
|
||||||
val currentSource by searchBarViewModel.source.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.Sync
|
||||||
import androidx.compose.material.icons.outlined.ThumbUp
|
import androidx.compose.material.icons.outlined.ThumbUp
|
||||||
import androidx.compose.material.icons.outlined.Translate
|
import androidx.compose.material.icons.outlined.Translate
|
||||||
|
import androidx.compose.material.icons.outlined.VideoSettings
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -146,6 +147,13 @@ fun AllSettingsScreen(
|
|||||||
onClick = { nav.nav(Route.UpdateReactionType) },
|
onClick = { nav.nav(Route.UpdateReactionType) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = R.string.video_player_settings,
|
||||||
|
icon = Icons.Outlined.VideoSettings,
|
||||||
|
tint = tint,
|
||||||
|
onClick = { nav.nav(Route.VideoPlayerSettings) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = R.string.zaps,
|
title = R.string.zaps,
|
||||||
icon = Icons.Outlined.Bolt,
|
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">Pay</string>
|
||||||
<string name="reactions_settings_pay_description">Send a payment to the author via their payment targets</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="profile_image_of_user">Profile Picture of %1$s</string>
|
||||||
<string name="relay_info">Relay %1$s</string>
|
<string name="relay_info">Relay %1$s</string>
|
||||||
<string name="expand_relay_list">Expand relay list</string>
|
<string name="expand_relay_list">Expand relay list</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user