feat(player): таймер сна с плавным затуханием (fade-out)
P0-фича из спеки. PlayerController: startSleepTimer/cancelSleepTimer — в последние 20с экспоненциальный fade-out громкости (frac^2), затем пауза + возврат громкости. В плеере — пилюля «Таймер сна» (иконка Moon): при активном показывает остаток M:SS акцентом. Шторка с интервалами 15/30/45/60/90/120 мин + «Выключить». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ import com.composables.icons.lucide.Heart
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Mic
|
||||
import com.composables.icons.lucide.MicOff
|
||||
import com.composables.icons.lucide.Moon
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.Pause
|
||||
import com.composables.icons.lucide.Play
|
||||
@@ -82,7 +83,9 @@ fun PlayerBottomSheet(
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var showLyrics by remember { mutableStateOf(false) }
|
||||
var showQuality by remember { mutableStateOf(false) }
|
||||
var showSleep by remember { mutableStateOf(false) }
|
||||
val currentQuality by viewModel.currentQuality.collectAsState()
|
||||
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
|
||||
val spectrum by viewModel.spectrum.collectAsState()
|
||||
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
||||
|
||||
@@ -344,6 +347,37 @@ fun PlayerBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка таймера сна. Активен → подсветка акцентом + оставшееся время MM:SS.
|
||||
val sleepSection: @Composable () -> Unit = {
|
||||
val active = sleepRemainingMs != null
|
||||
val sleepInteraction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (active) colors.accent.copy(alpha = 0.15f) else colors.surface2)
|
||||
.pressScale(interactionSource = sleepInteraction)
|
||||
.clickable(interactionSource = sleepInteraction, indication = null) {
|
||||
showSleep = true
|
||||
}
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Moon,
|
||||
contentDescription = null,
|
||||
tint = if (active) colors.accent else colors.textSecondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = sleepRemainingMs?.let { "Сон · ${formatSleep(it)}" } ?: "Таймер сна",
|
||||
color = if (active) colors.accent else colors.textSecondary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (landscape) {
|
||||
// Альбом: слева обложка с названием станции, справа — трек, эквалайзер,
|
||||
// управление и сервисы (правая панель скроллится на низких экранах).
|
||||
@@ -379,8 +413,10 @@ fun PlayerBottomSheet(
|
||||
controlsSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
servicesSection()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
sleepSection()
|
||||
if (track != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
lyricsSection()
|
||||
}
|
||||
}
|
||||
@@ -411,6 +447,8 @@ fun PlayerBottomSheet(
|
||||
Spacer(Modifier.height(20.dp))
|
||||
servicesSection()
|
||||
if (enabledServices.isNotEmpty()) Spacer(Modifier.height(12.dp))
|
||||
sleepSection()
|
||||
Spacer(Modifier.height(10.dp))
|
||||
lyricsSection()
|
||||
}
|
||||
}
|
||||
@@ -465,6 +503,108 @@ fun PlayerBottomSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Шторка таймера сна
|
||||
if (showSleep) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showSleep = false },
|
||||
containerColor = colors.bgBase,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Таймер сна",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Музыка плавно затихнет к концу и поставится на паузу.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.textSecondary,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
// Если активен — показываем остаток и кнопку отмены
|
||||
if (sleepRemainingMs != null) {
|
||||
SleepRow(
|
||||
label = "Осталось ${formatSleep(sleepRemainingMs!!)}",
|
||||
selected = true,
|
||||
onClick = {
|
||||
viewModel.cancelSleepTimer()
|
||||
showSleep = false
|
||||
},
|
||||
trailing = "Выключить"
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
listOf(15, 30, 45, 60, 90, 120).forEach { min ->
|
||||
SleepRow(
|
||||
label = "$min минут",
|
||||
selected = false,
|
||||
onClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.startSleepTimer(min)
|
||||
showSleep = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Строка выбора интервала таймера сна. */
|
||||
@Composable
|
||||
private fun SleepRow(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
trailing: String? = null
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Moon,
|
||||
contentDescription = null,
|
||||
tint = if (selected) colors.accent else colors.textSecondary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (selected) colors.accent else colors.textPrimary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (trailing != null) {
|
||||
Text(
|
||||
text = trailing,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colors.live,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */
|
||||
private fun formatSleep(ms: Long): String {
|
||||
val total = (ms / 1000).coerceAtLeast(0)
|
||||
return "%d:%02d".format(total / 60, total % 60)
|
||||
}
|
||||
|
||||
/** Компактный чип текущего качества звука. */
|
||||
|
||||
@@ -72,6 +72,12 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
|
||||
|
||||
// Таймер сна: оставшееся время в мс (null = выключен).
|
||||
val sleepRemainingMs: StateFlow<Long?> = playerController.sleepRemainingMs
|
||||
|
||||
fun startSleepTimer(minutes: Int) = playerController.startSleepTimer(minutes * 60_000L)
|
||||
fun cancelSleepTimer() = playerController.cancelSleepTimer()
|
||||
|
||||
private var nowPlayingJob: Job? = null
|
||||
|
||||
init {
|
||||
|
||||
Reference in New Issue
Block a user