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:
nk
2026-06-06 10:08:54 +03:00
parent 29cbe8997f
commit bda2c5b30f
3 changed files with 203 additions and 1 deletions

View File

@@ -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)
}
/** Компактный чип текущего качества звука. */