From ff28b7c9a84ab33bc218d0ea147c1b7619351dde Mon Sep 17 00:00:00 2001 From: mmdparsa Date: Sat, 19 Sep 2026 18:00:25 +0330 Subject: [PATCH] Issue #116 --- .../demonlab/lune/tools/PlaybackManager.kt | 23 ++ .../demonlab/lune/tools/SettingsManager.kt | 24 ++ .../ControlsCustomizationActivity.kt | 127 ++++++- .../GestureCustomizationActivity.kt | 177 +++++++++ .../com/demonlab/lune/ui/activities/Lune.kt | 3 +- .../lune/ui/activities/LyricsActivity.kt | 3 +- .../lune/ui/components/SharedComponents.kt | 203 +++++++++- .../lune/ui/player/PlayerComponents.kt | 357 +++++++++++++++--- app/src/main/res/values-ar/strings.xml | 26 ++ app/src/main/res/values-de/strings.xml | 26 ++ app/src/main/res/values-es/strings.xml | 15 + app/src/main/res/values-fa/strings.xml | 26 ++ app/src/main/res/values-fr/strings.xml | 26 ++ app/src/main/res/values-pt-rBR/strings.xml | 26 ++ app/src/main/res/values-ru/strings.xml | 26 ++ app/src/main/res/values-zh/strings.xml | 26 ++ app/src/main/res/values/strings.xml | 15 + 17 files changed, 1077 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/demonlab/lune/tools/PlaybackManager.kt b/app/src/main/java/com/demonlab/lune/tools/PlaybackManager.kt index 669c1de..f8784ac 100644 --- a/app/src/main/java/com/demonlab/lune/tools/PlaybackManager.kt +++ b/app/src/main/java/com/demonlab/lune/tools/PlaybackManager.kt @@ -1432,6 +1432,29 @@ class PlaybackManager private constructor(private val context: Context) { } } + fun addToQueue(song: Song) { + val current = currentSong + if (current == null || activePlaylist.isEmpty()) { + play(song) + return + } + val exists = activePlaylist.any { it.id == song.id } + if (exists) { + reorderQueueForSong(song, moveToFront = false) + } else { + val mutable = activePlaylist.toMutableList() + mutable.add(song) + activePlaylist = mutable + + if (isShuffle && shuffledIndices.isNotEmpty()) { + val newIndex = activePlaylist.size - 1 + val mutableShuffle = shuffledIndices.toMutableList() + mutableShuffle.add(newIndex) + shuffledIndices = mutableShuffle + } + } + } + fun reorderQueueForSong(song: Song, moveToFront: Boolean) { val current = currentSong ?: return if (moveToFront) { diff --git a/app/src/main/java/com/demonlab/lune/tools/SettingsManager.kt b/app/src/main/java/com/demonlab/lune/tools/SettingsManager.kt index 6781541..25783af 100644 --- a/app/src/main/java/com/demonlab/lune/tools/SettingsManager.kt +++ b/app/src/main/java/com/demonlab/lune/tools/SettingsManager.kt @@ -354,6 +354,30 @@ class SettingsManager(context: Context) { get() = prefs.getInt("swipe_up_action", 0) set(value) = prefs.edit().putInt("swipe_up_action", value).apply() + private val _isTrackSwipeEnabled = mutableStateOf(prefs.getBoolean("is_track_swipe_enabled", true)) + var isTrackSwipeEnabled: Boolean + get() = _isTrackSwipeEnabled.value + set(value) { + _isTrackSwipeEnabled.value = value + prefs.edit().putBoolean("is_track_swipe_enabled", value).apply() + } + + var trackSwipeRightAction: Int + get() = prefs.getInt("track_swipe_right_action", 0) + set(value) = prefs.edit().putInt("track_swipe_right_action", value).apply() + + var trackSwipeLeftAction: Int + get() = prefs.getInt("track_swipe_left_action", 1) + set(value) = prefs.edit().putInt("track_swipe_left_action", value).apply() + + private val _progressIndicatorStyle = mutableStateOf(prefs.getInt("progress_indicator_style", 0)) + var progressIndicatorStyle: Int + get() = _progressIndicatorStyle.value + set(value) { + _progressIndicatorStyle.value = value + prefs.edit().putInt("progress_indicator_style", value).apply() + } + var dailyListeningTime: Long get() = prefs.getLong("daily_listening_time", 0L) set(value) = prefs.edit().putLong("daily_listening_time", value).apply() diff --git a/app/src/main/java/com/demonlab/lune/ui/activities/ControlsCustomizationActivity.kt b/app/src/main/java/com/demonlab/lune/ui/activities/ControlsCustomizationActivity.kt index 4b4976c..fea5e9b 100644 --- a/app/src/main/java/com/demonlab/lune/ui/activities/ControlsCustomizationActivity.kt +++ b/app/src/main/java/com/demonlab/lune/ui/activities/ControlsCustomizationActivity.kt @@ -35,6 +35,7 @@ import com.demonlab.lune.tools.PlaybackManager import com.demonlab.lune.tools.SettingsManager import com.demonlab.lune.ui.components.AppBlurBackdrop import com.demonlab.lune.ui.player.ReusableSkipIcon +import com.demonlab.lune.ui.player.WaveformBarsProgressIndicator import com.demonlab.lune.ui.theme.LuneTheme import com.demonlab.lune.ui.theme.getControlsPrimaryColor import com.demonlab.lune.ui.utils.bounceClick @@ -85,7 +86,7 @@ class ControlsCustomizationActivity : ComponentActivity() { } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ControlsCustomizationScreen( onBack: () -> Unit, @@ -105,6 +106,7 @@ fun ControlsCustomizationScreen( var isControlsFilled by remember { mutableStateOf(settingsManager.isControlsFilled) } var useCustomControlsColor by remember { mutableStateOf(settingsManager.useCustomControlsColor) } var controlsColorPalette by remember { mutableIntStateOf(settingsManager.controlsColorPalette) } + var progressIndicatorStyle by remember { mutableIntStateOf(settingsManager.progressIndicatorStyle) } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -263,6 +265,64 @@ fun ControlsCustomizationScreen( MaterialTheme.colorScheme.onPrimary } + // Mock Seeker + val seekerColor = if (useCustomControlsColor) activePrimary else if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.primary + val seekerTrackColor = if (useCustomControlsColor) activePrimary.copy(alpha = 0.25f) else if (hasBlurBackground) Color.White.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant + + when (progressIndicatorStyle) { + 1 -> { + LinearProgressIndicator( + progress = { 0.45f }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + color = seekerColor, + trackColor = seekerTrackColor + ) + } + 2 -> { + WaveformBarsProgressIndicator( + progress = 0.45f, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + color = seekerColor, + trackColor = seekerTrackColor + ) + } + else -> { + LinearWavyProgressIndicator( + progress = { 0.45f }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + color = seekerColor, + trackColor = seekerTrackColor, + amplitude = { 1f } + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 6.dp, bottom = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "1:24", + style = MaterialTheme.typography.labelSmall, + color = if (hasBlurBackground) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "3:10", + style = MaterialTheme.typography.labelSmall, + color = if (hasBlurBackground) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Mock Player Bar Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), @@ -444,6 +504,71 @@ fun ControlsCustomizationScreen( Spacer(modifier = Modifier.height(16.dp)) + // Progress Indicator Style Selection + SettingsSection(title = stringResource(R.string.progress_indicator_style)) { + Surface( + modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + shape = RoundedCornerShape(28.dp), + color = if (hasBlurBackground) (if (isDarkTheme) Color.White.copy(alpha = 0.09f) else Color.Black.copy(alpha = 0.22f)) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + tonalElevation = if (hasBlurBackground) 0.dp else 1.dp + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val progressStyles = listOf( + Triple(0, stringResource(R.string.progress_style_wavy), Icons.Default.Waves), + Triple(1, stringResource(R.string.progress_style_slider), Icons.Default.LinearScale), + Triple(2, stringResource(R.string.progress_style_bars), Icons.Default.Equalizer) + ) + + progressStyles.forEach { (index, label, icon) -> + val isSelected = progressIndicatorStyle == index + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .bounceClick() + .clickable { + progressIndicatorStyle = index + settingsManager.progressIndicatorStyle = index + } + ) { + Surface( + shape = CircleShape, + color = if (isSelected) { + if (hasBlurBackground) Color.White.copy(alpha = 0.25f) else MaterialTheme.colorScheme.primaryContainer + } else { + if (hasBlurBackground) Color.White.copy(alpha = 0.10f) else MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) + }, + border = BorderStroke(2.dp, if (isSelected) (if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.primary) else Color.Transparent), + modifier = Modifier.size(72.dp) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (isSelected) (if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.primary) else (if (hasBlurBackground) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)), + modifier = Modifier.size(32.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) (if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.primary) else (if (hasBlurBackground) Color.White.copy(alpha = 0.75f) else MaterialTheme.colorScheme.onSurfaceVariant) + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + // Switches SettingsSection(title = stringResource(R.string.categories)) { SettingsPreferenceItem( diff --git a/app/src/main/java/com/demonlab/lune/ui/activities/GestureCustomizationActivity.kt b/app/src/main/java/com/demonlab/lune/ui/activities/GestureCustomizationActivity.kt index ef68487..d144ce9 100644 --- a/app/src/main/java/com/demonlab/lune/ui/activities/GestureCustomizationActivity.kt +++ b/app/src/main/java/com/demonlab/lune/ui/activities/GestureCustomizationActivity.kt @@ -12,10 +12,14 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Gesture import androidx.compose.material.icons.filled.SwipeUp +import androidx.compose.material.icons.filled.SwipeRight +import androidx.compose.material.icons.filled.SwipeLeft import androidx.compose.material3.* import com.demonlab.lune.ui.components.BouncySwitch import androidx.compose.runtime.* @@ -83,6 +87,12 @@ fun GestureCustomizationScreen( var swipeUpAction by remember { mutableIntStateOf(settingsManager.swipeUpAction) } var showSwipeUpOptions by remember { mutableStateOf(false) } + var isTrackSwipeEnabled by remember { mutableStateOf(settingsManager.isTrackSwipeEnabled) } + var trackSwipeRightAction by remember { mutableIntStateOf(settingsManager.trackSwipeRightAction) } + var trackSwipeLeftAction by remember { mutableIntStateOf(settingsManager.trackSwipeLeftAction) } + var showSwipeRightOptions by remember { mutableStateOf(false) } + var showSwipeLeftOptions by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() AppBlurBackdrop( @@ -172,6 +182,59 @@ fun GestureCustomizationScreen( onClick = { showSwipeUpOptions = true } ) } + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSection(title = stringResource(R.string.track_swipe_gestures)) { + val trackActionNames = listOf( + stringResource(R.string.play_next), + stringResource(R.string.add_to_queue), + stringResource(R.string.option_favorite), + stringResource(R.string.add_to_playlist), + stringResource(R.string.disabled) + ) + + SettingsPreferenceItem( + headlineText = stringResource(R.string.track_swipe_gestures), + supportingText = stringResource(R.string.track_swipe_gestures_desc), + icon = Icons.AutoMirrored.Filled.QueueMusic, + position = if (isTrackSwipeEnabled) SectionPosition.FIRST else SectionPosition.SINGLE, + trailingContent = { + BouncySwitch( + checked = isTrackSwipeEnabled, + onCheckedChange = { + isTrackSwipeEnabled = it + settingsManager.isTrackSwipeEnabled = it + }, + thumbContent = { + Icon( + imageVector = if (isTrackSwipeEnabled) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + ) + } + ) + + if (isTrackSwipeEnabled) { + SettingsPreferenceItem( + headlineText = stringResource(R.string.swipe_right_action), + supportingText = trackActionNames.getOrElse(trackSwipeRightAction) { stringResource(R.string.play_next) }, + icon = Icons.Default.SwipeRight, + position = SectionPosition.MIDDLE, + onClick = { showSwipeRightOptions = true } + ) + + SettingsPreferenceItem( + headlineText = stringResource(R.string.swipe_left_action), + supportingText = trackActionNames.getOrElse(trackSwipeLeftAction) { stringResource(R.string.add_to_queue) }, + icon = Icons.Default.SwipeLeft, + position = SectionPosition.LAST, + onClick = { showSwipeLeftOptions = true } + ) + } + } } if (showSwipeUpOptions) { @@ -230,6 +293,120 @@ fun GestureCustomizationScreen( } } } + + if (showSwipeRightOptions) { + val trackSwipeOptions = listOf( + stringResource(R.string.play_next), + stringResource(R.string.add_to_queue), + stringResource(R.string.option_favorite), + stringResource(R.string.add_to_playlist), + stringResource(R.string.disabled) + ) + ModalBottomSheet( + onDismissRequest = { showSwipeRightOptions = false }, + containerColor = if (hasBlurBackground) (if (isDarkTheme) Color(0xFF1E1E1E).copy(alpha = 0.95f) else Color(0xFFF5F5F5).copy(alpha = 0.95f)) else MaterialTheme.colorScheme.surface, + dragHandle = { + BottomSheetDefaults.DragHandle( + color = if (hasBlurBackground) Color.White.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) { + Column(modifier = Modifier.padding(bottom = 32.dp)) { + Text( + text = stringResource(R.string.swipe_right_action), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) + trackSwipeOptions.forEachIndexed { index, title -> + val isSelected = trackSwipeRightAction == index + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + trackSwipeRightAction = index + settingsManager.trackSwipeRightAction = index + showSwipeRightOptions = false + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.primary, + unselectedColor = if (hasBlurBackground) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = title, + color = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + + if (showSwipeLeftOptions) { + val trackSwipeOptions = listOf( + stringResource(R.string.play_next), + stringResource(R.string.add_to_queue), + stringResource(R.string.option_favorite), + stringResource(R.string.add_to_playlist), + stringResource(R.string.disabled) + ) + ModalBottomSheet( + onDismissRequest = { showSwipeLeftOptions = false }, + containerColor = if (hasBlurBackground) (if (isDarkTheme) Color(0xFF1E1E1E).copy(alpha = 0.95f) else Color(0xFFF5F5F5).copy(alpha = 0.95f)) else MaterialTheme.colorScheme.surface, + dragHandle = { + BottomSheetDefaults.DragHandle( + color = if (hasBlurBackground) Color.White.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) { + Column(modifier = Modifier.padding(bottom = 32.dp)) { + Text( + text = stringResource(R.string.swipe_left_action), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) + trackSwipeOptions.forEachIndexed { index, title -> + val isSelected = trackSwipeLeftAction == index + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + trackSwipeLeftAction = index + settingsManager.trackSwipeLeftAction = index + showSwipeLeftOptions = false + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.primary, + unselectedColor = if (hasBlurBackground) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = title, + color = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } } } } diff --git a/app/src/main/java/com/demonlab/lune/ui/activities/Lune.kt b/app/src/main/java/com/demonlab/lune/ui/activities/Lune.kt index 512b6f1..dc9e0d0 100644 --- a/app/src/main/java/com/demonlab/lune/ui/activities/Lune.kt +++ b/app/src/main/java/com/demonlab/lune/ui/activities/Lune.kt @@ -2398,7 +2398,8 @@ fun MainScreen( onNext = playNext, onSearchClick = { showSearchScreen = true }, onScrollToCurrent = { scrollToCurrentTrigger.value++ }, - onMinimize = { settingsManager.isMiniPlayerMinimized = true } + onMinimize = { settingsManager.isMiniPlayerMinimized = true }, + onSeek = { playbackManager.seekTo(it) } ) } } diff --git a/app/src/main/java/com/demonlab/lune/ui/activities/LyricsActivity.kt b/app/src/main/java/com/demonlab/lune/ui/activities/LyricsActivity.kt index e41ce25..3797dee 100644 --- a/app/src/main/java/com/demonlab/lune/ui/activities/LyricsActivity.kt +++ b/app/src/main/java/com/demonlab/lune/ui/activities/LyricsActivity.kt @@ -863,7 +863,8 @@ fun LyricsScreen(onBack: () -> Unit, isDarkTheme: Boolean = false) { onExpand = { /* already viewing lyrics */ }, onPrevious = playPrevious, onNext = playNext, - onMinimize = { isLyricsMiniPlayerMinimized = true } + onMinimize = { isLyricsMiniPlayerMinimized = true }, + onSeek = { playbackManager.seekTo(it) } ) } } diff --git a/app/src/main/java/com/demonlab/lune/ui/components/SharedComponents.kt b/app/src/main/java/com/demonlab/lune/ui/components/SharedComponents.kt index 2ad69e7..6b668ae 100644 --- a/app/src/main/java/com/demonlab/lune/ui/components/SharedComponents.kt +++ b/app/src/main/java/com/demonlab/lune/ui/components/SharedComponents.kt @@ -30,6 +30,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.MoreVert @@ -81,6 +85,13 @@ import com.demonlab.lune.tools.Song import com.demonlab.lune.ui.utils.formatDuration import com.demonlab.lune.ui.utils.formatDurationCompact import com.demonlab.lune.ui.utils.formatLongDuration +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import android.os.Vibrator +import android.widget.Toast +import com.demonlab.lune.ui.utils.triggerLightVibration +import kotlin.math.roundToInt +import kotlin.math.abs import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -316,16 +327,195 @@ fun SongItem( val optionsBg = if (hasBlurBackground) Color.White.copy(alpha = 0.15f) else MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) val optionsTint = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.primary - Surface( + val isTrackSwipeEnabled = settingsManager.isTrackSwipeEnabled + val rightAction = settingsManager.trackSwipeRightAction + val leftAction = settingsManager.trackSwipeLeftAction + val vibrator = remember(context) { context.getSystemService(Vibrator::class.java) } + val playbackManager = remember { PlaybackManager.getInstance(context) } + val coroutineScope = rememberCoroutineScope() + val offsetX = remember { Animatable(0f) } + var hasVibratedThreshold by remember { mutableStateOf(false) } + + fun executeTrackAction(action: Int) { + if (settingsManager.isHapticVibrationEnabled) { + vibrator?.triggerLightVibration() + } + when (action) { + 0 -> { + playbackManager.playNext(song) + Toast.makeText(context, context.getString(R.string.played_next, song.title), Toast.LENGTH_SHORT).show() + } + 1 -> { + playbackManager.addToQueue(song) + Toast.makeText(context, context.getString(R.string.added_to_queue, song.title), Toast.LENGTH_SHORT).show() + } + 2 -> { + onFavoriteClick?.invoke(song) ?: run { + playbackManager.toggleFavorite(song) + } + } + 3 -> { + onOptionsClick?.invoke() + } + } + } + + val currentOffset = offsetX.value + val absOffset = abs(currentOffset) + val threshold = 72f + val isPastThreshold = absOffset >= threshold + + Box( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 1.dp) - .bounceClick(scaleDown = 0.96f), - onClick = onClick ?: {}, - shape = shape, - color = cardBg, - border = itemBorder + .clip(shape) ) { + if (isTrackSwipeEnabled && absOffset > 4f) { + val isSwipingRight = currentOffset > 0 + val action = if (isSwipingRight) rightAction else leftAction + if (action != 4) { + val (actionIcon, actionLabel) = when (action) { + 0 -> Icons.AutoMirrored.Filled.PlaylistPlay to stringResource(R.string.play_next) + 1 -> Icons.AutoMirrored.Filled.QueueMusic to stringResource(R.string.add_to_queue) + 2 -> (if (song.isFavorite) Icons.Default.FavoriteBorder else Icons.Default.Favorite) to stringResource(R.string.option_favorite) + 3 -> Icons.AutoMirrored.Filled.PlaylistAdd to stringResource(R.string.add_to_playlist) + else -> Icons.Default.Check to "" + } + + val actionBgColor = if (isPastThreshold) { + if (hasBlurBackground) Color.White.copy(alpha = 0.35f) else activePrimary.copy(alpha = 0.30f) + } else { + if (hasBlurBackground) Color.White.copy(alpha = 0.15f) else activePrimary.copy(alpha = 0.12f) + } + + val actionIconTint = if (hasBlurBackground) Color.White else activePrimary + + Box( + modifier = Modifier + .matchParentSize() + .background(actionBgColor) + .padding(horizontal = 18.dp), + contentAlignment = if (isSwipingRight) Alignment.CenterStart else Alignment.CenterEnd + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.graphicsLayer { + val scale = if (isPastThreshold) 1.15f else (0.8f + (absOffset / threshold) * 0.2f).coerceIn(0.8f, 1f) + scaleX = scale + scaleY = scale + } + ) { + if (isSwipingRight) { + Icon( + imageVector = actionIcon, + contentDescription = actionLabel, + tint = actionIconTint, + modifier = Modifier.size(24.dp) + ) + if (isPastThreshold) { + Text( + text = actionLabel, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = actionIconTint + ) + } + } else { + if (isPastThreshold) { + Text( + text = actionLabel, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = actionIconTint + ) + } + Icon( + imageVector = actionIcon, + contentDescription = actionLabel, + tint = actionIconTint, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .then( + if (isTrackSwipeEnabled) { + Modifier.pointerInput(song.id, isTrackSwipeEnabled, rightAction, leftAction) { + detectHorizontalDragGestures( + onDragStart = { + hasVibratedThreshold = false + }, + onDragEnd = { + val finalOffset = offsetX.value + if (abs(finalOffset) >= threshold) { + val action = if (finalOffset > 0) rightAction else leftAction + if (action != 4) { + executeTrackAction(action) + } + } + hasVibratedThreshold = false + coroutineScope.launch { + offsetX.animateTo( + 0f, + spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow) + ) + } + }, + onDragCancel = { + hasVibratedThreshold = false + coroutineScope.launch { + offsetX.animateTo( + 0f, + spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow) + ) + } + }, + onHorizontalDrag = { change, dragAmount -> + val current = offsetX.value + val target = current + dragAmount * 0.65f + val canSwipeRight = rightAction != 4 + val canSwipeLeft = leftAction != 4 + + val clamped = if (target > 0 && !canSwipeRight) { + target.coerceAtMost(10f) + } else if (target < 0 && !canSwipeLeft) { + target.coerceAtLeast(-10f) + } else { + target.coerceIn(-140f, 140f) + } + + if (abs(clamped) >= threshold && !hasVibratedThreshold) { + hasVibratedThreshold = true + if (settingsManager.isHapticVibrationEnabled) { + vibrator?.triggerLightVibration() + } + } else if (abs(clamped) < threshold) { + hasVibratedThreshold = false + } + + coroutineScope.launch { + offsetX.snapTo(clamped) + } + } + ) + } + } else Modifier + ) + .bounceClick(scaleDown = 0.96f), + onClick = onClick ?: {}, + shape = shape, + color = cardBg, + border = itemBorder + ) { ListItem( colors = ListItemDefaults.colors(containerColor = Color.Transparent), supportingContent = { @@ -475,6 +665,7 @@ fun SongItem( } } } +} @Composable fun SongGridItem( diff --git a/app/src/main/java/com/demonlab/lune/ui/player/PlayerComponents.kt b/app/src/main/java/com/demonlab/lune/ui/player/PlayerComponents.kt index f80f890..cf57e7d 100644 --- a/app/src/main/java/com/demonlab/lune/ui/player/PlayerComponents.kt +++ b/app/src/main/java/com/demonlab/lune/ui/player/PlayerComponents.kt @@ -18,15 +18,22 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.IntOffset +import com.demonlab.lune.ui.utils.triggerLightVibration import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin @@ -392,6 +399,51 @@ fun AudioQualityBadges( } } +@Composable +fun WaveformBarsProgressIndicator( + progress: Float, + color: Color, + trackColor: Color, + modifier: Modifier = Modifier, + barCount: Int = 40 +) { + val barHeights = remember(barCount) { + val random = java.util.Random(1337) + FloatArray(barCount) { index -> + val fraction = index.toFloat() / barCount + val wave = kotlin.math.sin(fraction * Math.PI).toFloat().coerceIn(0.2f, 1f) + val noise = 0.35f + random.nextFloat() * 0.65f + (wave * noise).coerceIn(0.25f, 1f) + } + } + + Canvas( + modifier = modifier + .fillMaxWidth() + .height(24.dp) + ) { + val totalWidth = size.width + val barWidth = 4.dp.toPx() + val spacing = (totalWidth - (barWidth * barCount)) / (barCount - 1).coerceAtLeast(1) + val maxHeight = size.height + + for (i in 0 until barCount) { + val barFraction = (i.toFloat() + 0.5f) / barCount + val isFilled = barFraction <= progress.coerceIn(0f, 1f) + val barH = (maxHeight * barHeights[i]).coerceAtLeast(barWidth) + val x = i * (barWidth + spacing) + val y = (maxHeight - barH) / 2f + + drawRoundRect( + color = if (isFilled) color else trackColor, + topLeft = Offset(x, y), + size = Size(barWidth, barH), + cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f) + ) + } + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun FullPlayer( @@ -948,15 +1000,41 @@ fun FullPlayer( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - LinearWavyProgressIndicator( - progress = { progress }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - color = if (useBlurControls) Color.White else MaterialTheme.colorScheme.primary, - trackColor = if (useBlurControls) Color.White.copy(alpha = 0.3f) else MaterialTheme.colorScheme.surfaceVariant, - amplitude = { 1f } - ) + when (settingsManager.progressIndicatorStyle) { + 1 -> { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = if (useBlurControls) Color.White else MaterialTheme.colorScheme.primary, + trackColor = if (useBlurControls) Color.White.copy(alpha = 0.3f) else MaterialTheme.colorScheme.surfaceVariant + ) + } + 2 -> { + WaveformBarsProgressIndicator( + progress = progress, + color = if (useBlurControls) Color.White else MaterialTheme.colorScheme.primary, + trackColor = if (useBlurControls) Color.White.copy(alpha = 0.3f) else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) + } + else -> { + LinearWavyProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + color = if (useBlurControls) Color.White else MaterialTheme.colorScheme.primary, + trackColor = if (useBlurControls) Color.White.copy(alpha = 0.3f) else MaterialTheme.colorScheme.surfaceVariant, + amplitude = { 1f } + ) + } + } val infiniteTransition = rememberInfiniteTransition(label = "thumbRotation") val rotation by infiniteTransition.animateFloat( @@ -1929,6 +2007,132 @@ fun ScallopPlayPauseButtonWithProgress( } } +fun Modifier.miniPlayerSeekAndSwipeGestures( + enabled: Boolean, + vibrator: Vibrator?, + isHapticEnabled: Boolean, + onExpand: () -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onSeek: ((Float) -> Unit)?, + isScrubbing: Boolean, + onScrubbingChange: (Boolean) -> Unit, + onScrubProgressChange: (Float) -> Unit +): Modifier = composed { + if (!enabled) return@composed this + + val coroutineScope = rememberCoroutineScope() + var dragTranslationX by remember { mutableFloatStateOf(0f) } + var dragScale by remember { mutableFloatStateOf(1f) } + val animOffsetX = remember { Animatable(0f) } + val animScale = remember { Animatable(1f) } + var isDragging by remember { mutableStateOf(false) } + + this + .graphicsLayer { + if (!isScrubbing) { + translationX = if (isDragging) dragTranslationX else animOffsetX.value + scaleX = if (isDragging) dragScale else animScale.value + scaleY = if (isDragging) dragScale else animScale.value + } + } + .pointerInput(enabled, onSeek) { + val touchSlop = viewConfiguration.touchSlop + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val downTime = System.currentTimeMillis() + val startX = down.position.x + val totalWidth = size.width.toFloat() + var scrubActive = false + var swipeConsumed = false + var hasMovedPastSlop = false + isDragging = false + dragTranslationX = 0f + dragScale = 1f + var latestScrubFraction = 0f + + val longPressJob = coroutineScope.launch { + if (onSeek != null) { + delay(280L) + if (!hasMovedPastSlop) { + scrubActive = true + if (isHapticEnabled) vibrator?.triggerLightVibration() + onScrubbingChange(true) + latestScrubFraction = (startX / totalWidth.coerceAtLeast(1f)).coerceIn(0f, 1f) + onScrubProgressChange(latestScrubFraction) + } + } + } + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + longPressJob.cancel() + if (scrubActive) { + change.consume() + onSeek?.invoke(latestScrubFraction) + onScrubbingChange(false) + if (isHapticEnabled) vibrator?.triggerLightVibration() + } else { + val duration = System.currentTimeMillis() - downTime + val totalDeltaX = change.position.x - startX + if (swipeConsumed) { + // Already consumed skip + } else if (duration < 320 && kotlin.math.abs(totalDeltaX) < touchSlop) { + onExpand() + } + isDragging = false + val currentX = dragTranslationX + val currentS = dragScale + coroutineScope.launch { + animOffsetX.snapTo(currentX) + animScale.snapTo(currentS) + if (swipeConsumed) { + animScale.animateTo(0.94f, tween(50)) + } + launch { + animOffsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow)) + } + launch { + animScale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow)) + } + } + } + break + } + + val currentX = change.position.x + val deltaX = currentX - startX + + if (kotlin.math.abs(deltaX) > touchSlop) { + hasMovedPastSlop = true + if (!scrubActive) { + longPressJob.cancel() + } + } + + if (scrubActive) { + change.consume() + latestScrubFraction = (currentX / totalWidth.coerceAtLeast(1f)).coerceIn(0f, 1f) + onScrubProgressChange(latestScrubFraction) + } else { + isDragging = true + dragTranslationX = (deltaX * 0.35f).coerceIn(-75f, 75f) + val absX = kotlin.math.abs(dragTranslationX) + dragScale = (1f - (absX / 1600f)).coerceIn(0.96f, 1f) + + if (!swipeConsumed && absX > 45) { + change.consume() + swipeConsumed = true + if (dragTranslationX < 0) onNext() else onPrevious() + } + } + } + } + } +} + @Composable fun MiniPlayer( song: Song, @@ -1954,7 +2158,8 @@ fun MiniPlayer( onNext: () -> Unit, onSearchClick: (() -> Unit)? = null, onScrollToCurrent: (() -> Unit)? = null, - onMinimize: (() -> Unit)? = null + onMinimize: (() -> Unit)? = null, + onSeek: ((Float) -> Unit)? = null ) { val infiniteSpinTransition = rememberInfiniteTransition(label = "MiniPlayerSpin") val spinRotation by infiniteSpinTransition.animateFloat( @@ -1968,9 +2173,20 @@ fun MiniPlayer( ) val miniContext = LocalContext.current + val vibrator = remember(miniContext) { miniContext.getSystemService(Vibrator::class.java) } + val settingsManager = remember { SettingsManager.getInstance(miniContext) } val blurContainerColorMini = if (isDarkTheme) Color.Black.copy(alpha = 0.25f) else Color.White.copy(alpha = 0.4f) val activePrimary = getControlsPrimaryColor(useCustomControlsColor, controlsColorPalette) + var isScrubbing by remember { mutableStateOf(false) } + var liveScrubProgress by remember { mutableFloatStateOf(progress) } + + LaunchedEffect(progress) { + if (!isScrubbing) { + liveScrubProgress = progress + } + } + val pillMiniColor = if (useCustomControlsColor) { activePrimary.copy(alpha = 0.25f) } else if (hasBlurBackground) { @@ -2000,12 +2216,18 @@ fun MiniPlayer( .weight(1f) .fillMaxHeight() .clip(pillShape) - .songSwipeGestures( + .miniPlayerSeekAndSwipeGestures( enabled = true, + vibrator = vibrator, + isHapticEnabled = settingsManager.isHapticVibrationEnabled, + onExpand = onExpand, onNext = onNext, - onPrevious = onPrevious - ) - .clickable { onExpand() }, + onPrevious = onPrevious, + onSeek = onSeek, + isScrubbing = isScrubbing, + onScrubbingChange = { isScrubbing = it }, + onScrubProgressChange = { liveScrubProgress = it } + ), shape = pillShape, color = if (hasBlurBackground) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.primaryContainer, tonalElevation = if (hasBlurBackground) 0.dp else 6.dp @@ -2057,35 +2279,84 @@ fun MiniPlayer( .padding(start = 18.dp, end = 6.dp), verticalAlignment = Alignment.CenterVertically ) { - // Título de la canción y debajo icono de dispositivo + artista - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center - ) { - Text( - text = song.title, - modifier = Modifier.basicMarquee(), - color = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - maxLines = 1 - ) - Spacer(modifier = Modifier.height(2.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = currentOutputIcon, - contentDescription = null, - tint = if (hasBlurBackground) Color.White.copy(alpha = 0.85f) else MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), - modifier = Modifier.size(13.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = song.artist, - modifier = Modifier.basicMarquee(), - color = if (hasBlurBackground) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - maxLines = 1 - ) + AnimatedContent( + targetState = isScrubbing, + transitionSpec = { + fadeIn(tween(160)) togetherWith fadeOut(tween(160)) + }, + label = "MiniPlayerScrubTransition", + modifier = Modifier.weight(1f) + ) { scrubbing -> + if (scrubbing) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatDuration((song.duration * liveScrubProgress).toLong()), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.mini_player_scrubbing), + style = MaterialTheme.typography.labelSmall, + color = if (hasBlurBackground) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + Text( + text = formatDuration(song.duration), + style = MaterialTheme.typography.labelSmall, + color = if (hasBlurBackground) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(3.dp)) + LinearProgressIndicator( + progress = { liveScrubProgress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = if (hasBlurBackground) Color.White else (if (useCustomControlsColor) activePrimary else MaterialTheme.colorScheme.primary), + trackColor = if (hasBlurBackground) Color.White.copy(alpha = 0.25f) else MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.25f) + ) + } + } else { + // Título de la canción y debajo icono de dispositivo + artista + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center + ) { + Text( + text = song.title, + modifier = Modifier.basicMarquee(), + color = if (hasBlurBackground) Color.White else MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + Spacer(modifier = Modifier.height(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = currentOutputIcon, + contentDescription = null, + tint = if (hasBlurBackground) Color.White.copy(alpha = 0.85f) else MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), + modifier = Modifier.size(13.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = song.artist, + modifier = Modifier.basicMarquee(), + color = if (hasBlurBackground) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + maxLines = 1 + ) + } + } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 73c251d..746b5c0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -131,6 +131,7 @@ تعيين مدة انتقال التداخل الصوتي فنان غير معروف أغنية غير معروفة + ألبوم غير معروف رجوع @@ -156,6 +157,16 @@ موافق تفعيل معادل الصوت تشغيل/إيقاف مؤقت + تشغيل + إيقاف مؤقت + تفاصيل جودة الصوت + بدون فقدان (Lossless) + مضغوط + معدل البت + معدل العينة + عمق البت + صيغة الملف + مسار الملف تغيير الغلاف قوائم التشغيل قائمة تشغيل @@ -456,4 +467,19 @@ مزامنة النسخ الاحتياطي تلقائياً تحديث النسخة الاحتياطية المصدرة تلقائياً عند تغيير القوائم أو الكلمات قم بتصدير نسخة احتياطية أولاً لتفعيل المزامنة التلقائية + + + إيماءات السحب للمسارات + اسحب لليسار أو لليمين على المسارات للإضافة إلى قائمة الانتظار أو تنفيذ إجراءات سريعة + إجراء السحب لليمين + إجراء السحب لليسار + إضافة إلى قائمة الانتظار + تمت الإضافة إلى قائمة الانتظار: %1$s + تشغيل التالي: %1$s + نمط شريط التقدم + اختر نمط شريط التقدم في شاشة المشغل + متموج + شريط تقليدي + أعمدة ترددية + اسحب للتمرير السريع diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2b31340..a28c819 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -132,6 +132,7 @@ Übergangsdauer für Crossfade festlegen Unbekannter Künstler Unbekannter Song + Unbekanntes Album Zurück @@ -157,6 +158,16 @@ OK EQ aktivieren Play/Pause + Wiedergabe + Pause + Audioqualitätsdetails + Verlustfrei + Komprimiert + Bitrate + Abtastrate + Bittiefe + Dateiformat + Dateipfad Cover ändern Playlists Playlist @@ -455,4 +466,19 @@ Automatische Backup-Synchronisation Aktualisiert das exportierte Backup automatisch bei Änderungen an Playlists oder Songtexten Exportiere zuerst ein Backup, um die Autosynchronisation zu aktivieren + + + Titel-Wischgesten + Nach links oder rechts wischen, um Titel schnell zur Warteschlange hinzuzufügen oder Aktionen auszuführen + Aktion für Wischen nach rechts + Aktion für Wischen nach links + Zur Warteschlange hinzufügen + Zur Warteschlange hinzugefügt: %1$s + Wird als nächstes gespielt: %1$s + Fortschrittsbalken-Stil + Wählen Sie den Fortschrittsbalken-Stil im Wiedergabebildschirm + Wellenförmig + Schieberegler + Wellenform-Balken + Ziehen zum Spulen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9c1d94c..4b2b01e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -469,4 +469,19 @@ Sincronización automática de respaldo Actualiza automáticamente el respaldo cuando cambien tus listas o letras Exporta un respaldo primero para activar la sincronización + + + Acciones al deslizar pistas + Desliza a la izquierda o derecha en las canciones para encolar o realizar acciones rápidas + Acción al deslizar a la derecha + Acción al deslizar a la izquierda + Añadir a la cola + Añadido a la cola: %1$s + Reproduciendo a continuación: %1$s + Estilo de la barra de progreso + Elige el estilo de la barra de reproducción en la pantalla del reproductor + Ondulada + Deslizador clásico + Barras de onda + Desliza para buscar diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 03aaff0..5fdd499 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -131,6 +131,7 @@ تنظیم کن چقدر طول بکشه تا آهنگا تو هم محو بشن خواننده ناشناس آهنگ ناشناس + آلبوم ناشناس برگرد @@ -156,6 +157,16 @@ حله روشن کردن اکولایزر پلی / استاپ + پخش + توقف + جزئیات کیفیت صدا + بدون افت کیفیت (Lossless) + فشرده‌شده + بیت‌ریت + نرخ نمونه‌برداری + عمق بیت + فرمت فایل + مسیر فایل عوض کردن کاور پلی‌لیست‌ها اضافه به پلی‌لیست @@ -454,4 +465,19 @@ همگام‌سازی خودکار بکاپ با تغییر پلی‌لیست‌ها یا متن‌ها، فایل بکاپ به طور خودکار بروزرسانی میشه واسه فعال کردن این بخش، اول باید یه خروجی بگیری + + + ژست‌های کشیدن آهنگ + کشیدن آهنگ به چپ یا راست برای افزودن سریع به صف یا سایر اقدامات + اقدام کشیدن به راست + اقدام کشیدن به چپ + افزودن به صف + به صف اضافه شد: %1$s + پخش بعدی: %1$s + طرح نوار پیشرفت + انتخاب طرح نوار زمان در صفحه پخش‌کننده + موج‌دار + خطی ساده + میله‌های فرکانس + بکشید برای جلو/عقب زدن diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1b2e8a8..fbad87c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -132,6 +132,7 @@ Définir la durée de la transition Artiste inconnu Chanson inconnue + Album inconnu Retour @@ -157,6 +158,16 @@ OK Activer l’EQ Lecture/Pause + Lecture + Pause + Détails de la qualité audio + Sans perte + Compressé + Débit binaire + Taux d\'échantillonnage + Profondeur de bits + Format de fichier + Chemin du fichier Changer la pochette Playlists Ajouter à la playlist @@ -458,4 +469,19 @@ Sauvegarde synchronisée automatiquement Met à jour automatiquement la sauvegarde exportée lors de modifications Exportez d\'abord une sauvegarde pour activer la synchronisation automatique + + + Actions de glissement de piste + Glissez vers la gauche ou la droite sur les morceaux pour les mettre en file d\'attente ou effectuer des actions + Action glisser vers la droite + Action glisser vers la gauche + Ajouter à la file d\'attente + Ajouté à la file d\'attente : %1$s + Lecture suivante : %1$s + Style de la barre de progression + Choisissez le style de la barre de progression dans le lecteur + Ondulée + Curseur classique + Barres d\'ondes + Glisser pour chercher diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ff20e15..ecea4b0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -132,6 +132,7 @@ Definir a duração da transição Artista desconhecido Música desconhecida + Álbum desconhecido Voltar @@ -157,6 +158,16 @@ OK Ativar EQ Reproduzir/Pausar + Reproduzir + Pausar + Detalhes da qualidade de áudio + Sem perdas (Lossless) + Comprimido + Taxa de bits + Taxa de amostragem + Profundidade de bits + Formato do arquivo + Caminho do arquivo Alterar capa Playlists Playlist @@ -458,4 +469,19 @@ Sincronização automática do backup Atualiza automaticamente o backup exportado quando playlists ou letras mudarem Exporte um backup primeiro para ativar a sincronização automática + + + Ações de deslizar faixas + Deslize para a esquerda ou direita nas faixas para adicionar à fila ou realizar ações rápidas + Ação ao deslizar para a direita + Ação ao deslizar para a esquerda + Adicionar à fila + Adicionado à fila: %1$s + Tocando a seguir: %1$s + Estilo da barra de progresso + Escolha o estilo do indicador de progresso na tela do player + Ondulado + Controle deslizante + Barras de onda + Deslize para buscar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index dfa1af4..b35e426 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -132,6 +132,7 @@ Установить длительность перехода кроссфейда Неизвестный исполнитель Неизвестная песня + Неизвестный альбом Назад @@ -157,6 +158,16 @@ ОК Включить эквалайзер Играть/Пауза + Воспроизведение + Пауза + Сведения о качестве аудио + Без потерь (Lossless) + Сжатый + Битрейт + Частота дискретизации + Разрядность + Формат файла + Путь к файлу Изменить обложку Плейлисты В плейлист @@ -458,4 +469,19 @@ Автосинхронизация резервной копии Автоматически обновлять экспортированную копию при изменении плейлистов или текстов Сначала экспортируйте резервную копию для включения автосинхронизации + + + Жесты смахивания треков + Смахивайте треки влево или вправо, чтобы быстро добавить их в очередь или выполнить действия + Действие при смахивании вправо + Действие при смахивании влево + Добавить в очередь + Добавлено в очередь: %1$s + Следующий трек: %1$s + Стиль индикатора выполнения + Выберите стиль полосы перемотки на экране плеера + Волнистый + Обычный ползунок + Полосы спектра + Проведите для перемотки diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f4330d4..d0f5304 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -132,6 +132,7 @@ 设置交叉淡化的过渡时长 未知艺术家 未知歌曲 + 未知专辑 返回 @@ -157,6 +158,16 @@ 确定 启用均衡器 播放/暂停 + 播放 + 暂停 + 音质详情 + 无损 + 有损压缩 + 比特率 + 采样率 + 位深度 + 文件格式 + 文件路径 更换封面 播放列表 播放列表 @@ -455,4 +466,19 @@ 自动同步备份 播放列表或歌词更改时自动更新已导出的备份 请先导出备份以启用自动同步 + + + 曲目滑动操作 + 在歌曲上向左或向右滑动以快速加入队列或执行快捷操作 + 右滑操作 + 左滑操作 + 加入队列 + 已加入队列:%1$s + 下一首播放:%1$s + 进度条样式 + 选择播放器界面的进度条样式 + 波浪形 + 经典滑块 + 频谱音柱 + 滑动进行快进/快退 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 641c344..78389f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -468,4 +468,19 @@ Auto-sync backup Automatically update the exported backup when playlists or lyrics change Export a backup first to enable auto-sync + + + Track swipe actions + Swipe left or right on tracks to quickly queue or perform actions + Swipe right action + Swipe left action + Add to queue + Added to queue: %1$s + Playing next: %1$s + Progress bar style + Choose the seeker style in the player screen + Wavy + Slider + Bars + Slide to seek