feat(player): звуки для сна (белый/розовый/коричневый шум) + Smart Sleep Fade

SleepSoundPlayer — процедурная генерация цветного шума через AudioTrack (розовый —
фильтр Келлета, коричневый — random walk). В таймере сна выбор звука: радио плавно
перетекает в выбранный шум (кроссфейд ≤90с), шум играет, к концу затухает — как в
спеке («Smart Sleep Fade»). В шторке таймера — чипы выбора звука.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-06 15:08:32 +03:00
parent bda2c5b30f
commit 4411d53a6c
4 changed files with 207 additions and 16 deletions

View File

@@ -84,6 +84,7 @@ fun PlayerBottomSheet(
var showLyrics by remember { mutableStateOf(false) }
var showQuality by remember { mutableStateOf(false) }
var showSleep by remember { mutableStateOf(false) }
var selectedSound by remember { mutableStateOf<com.radiola.service.SleepSound?>(null) }
val currentQuality by viewModel.currentQuality.collectAsState()
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
val spectrum by viewModel.spectrum.collectAsState()
@@ -526,11 +527,23 @@ fun PlayerBottomSheet(
modifier = Modifier.padding(vertical = 12.dp)
)
Text(
text = "Музыка плавно затихнет к концу и поставится на паузу.",
text = "Музыка плавно затихнет к концу. Можно мягко перейти на звук для сна.",
style = MaterialTheme.typography.bodySmall,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 12.dp)
)
// Выбор звука для сна: радио плавно перетечёт в выбранный шум.
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 12.dp)
) {
item {
SoundChip("Без звука", selectedSound == null) { selectedSound = null }
}
items(com.radiola.service.SleepSound.entries) { snd ->
SoundChip(snd.title, selectedSound == snd) { selectedSound = snd }
}
}
// Если активен — показываем остаток и кнопку отмены
if (sleepRemainingMs != null) {
SleepRow(
@@ -550,7 +563,7 @@ fun PlayerBottomSheet(
selected = false,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.startSleepTimer(min)
viewModel.startSleepTimer(min, selectedSound)
showSleep = false
}
)
@@ -601,6 +614,27 @@ private fun SleepRow(
}
}
/** Чип выбора звука для сна. */
@Composable
private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(if (selected) colors.accent else colors.surface2)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 14.dp, vertical = 9.dp)
)
}
/** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */
private fun formatSleep(ms: Long): String {
val total = (ms / 1000).coerceAtLeast(0)

View File

@@ -75,7 +75,8 @@ class PlayerViewModel @Inject constructor(
// Таймер сна: оставшееся время в мс (null = выключен).
val sleepRemainingMs: StateFlow<Long?> = playerController.sleepRemainingMs
fun startSleepTimer(minutes: Int) = playerController.startSleepTimer(minutes * 60_000L)
fun startSleepTimer(minutes: Int, sound: com.radiola.service.SleepSound? = null) =
playerController.startSleepTimer(minutes * 60_000L, sound)
fun cancelSleepTimer() = playerController.cancelSleepTimer()
private var nowPlayingJob: Job? = null