diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index 54fe575..722ec4d 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -40,7 +40,8 @@ import javax.inject.Singleton @UnstableApi @Singleton class PlayerController @Inject constructor( - @ApplicationContext context: Context + @ApplicationContext context: Context, + private val sleepSoundPlayer: SleepSoundPlayer ) { // Анализатор спектра реального звука — для «живого» эквалайзера. private val spectrumAnalyzer = AudioSpectrumAnalyzer() @@ -84,6 +85,8 @@ class PlayerController @Inject constructor( private companion object { const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера + const val CROSSFADE_MS = 90_000L // переход радио → звук для сна + const val SOUND_VOL = 0.6f // комфортная громкость шума } private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager @@ -247,39 +250,71 @@ class PlayerController @Inject constructor( } /** - * Запустить таймер сна на [durationMs] мс. В последние FADE_MS звук плавно - * затухает (экспоненциально, чтобы спад воспринимался естественно), затем пауза. + * Запустить таймер сна на [durationMs] мс. + * Без [sound]: в последние FADE_MS радио экспоненциально затухает, затем пауза. + * Со [sound] («Smart Sleep Fade»): в начале радио кроссфейдится в звук для сна + * (радио ↓, шум ↑), затем шум играет, в конце затухает — как в спеке. */ - fun startSleepTimer(durationMs: Long) { + fun startSleepTimer(durationMs: Long, sound: SleepSound? = null) { sleepJob?.cancel() exoPlayer.volume = 1f + sleepSoundPlayer.stop() sleepJob = timerScope.launch { - val end = SystemClock.elapsedRealtime() + durationMs + val start = SystemClock.elapsedRealtime() + val end = start + durationMs + // Кроссфейд не длиннее трети таймера (для коротких интервалов). + val crossfade = if (sound != null) CROSSFADE_MS.coerceAtMost(durationMs / 3) else 0L + if (sound != null) sleepSoundPlayer.start(sound) while (true) { - val remaining = end - SystemClock.elapsedRealtime() + val now = SystemClock.elapsedRealtime() + val remaining = end - now if (remaining <= 0L) break _sleepRemainingMs.value = remaining - if (remaining <= FADE_MS) { - // frac: 1 → 0; экспонента (frac^2) — громкость падает резче к концу. - val frac = remaining.toFloat() / FADE_MS - exoPlayer.volume = (frac * frac).coerceIn(0f, 1f) + if (sound != null) { + val elapsed = now - start + when { + elapsed < crossfade -> { + // Кроссфейд: радио вниз, шум вверх. + val f = elapsed.toFloat() / crossfade + exoPlayer.volume = (1f - f).coerceIn(0f, 1f) + sleepSoundPlayer.setVolume(f * SOUND_VOL) + } + remaining <= FADE_MS -> { + // Финальное затухание шума. + val frac = remaining.toFloat() / FADE_MS + sleepSoundPlayer.setVolume((frac * frac) * SOUND_VOL) + } + else -> { + // Радио отыграло — на паузу, шум на комфортной громкости. + if (exoPlayer.isPlaying) exoPlayer.pause() + sleepSoundPlayer.setVolume(SOUND_VOL) + } + } delay(150) } else { - delay(1_000) + if (remaining <= FADE_MS) { + val frac = remaining.toFloat() / FADE_MS + exoPlayer.volume = (frac * frac).coerceIn(0f, 1f) + delay(150) + } else { + delay(1_000) + } } } - exoPlayer.pause() + if (exoPlayer.isPlaying) exoPlayer.pause() exoPlayer.volume = 1f + sleepSoundPlayer.stop() _sleepRemainingMs.value = null sleepJob = null } } - /** Отменить таймер сна и вернуть полную громкость. */ + /** Отменить таймер сна, вернуть громкость и заглушить звук сна. */ fun cancelSleepTimer() { sleepJob?.cancel() sleepJob = null exoPlayer.volume = 1f + sleepSoundPlayer.stop() _sleepRemainingMs.value = null } @@ -299,6 +334,7 @@ class PlayerController @Inject constructor( fun release() { audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + sleepSoundPlayer.stop() exoPlayer.release() } } diff --git a/app/src/main/java/com/radiola/service/SleepSoundPlayer.kt b/app/src/main/java/com/radiola/service/SleepSoundPlayer.kt new file mode 100644 index 0000000..eb695c0 --- /dev/null +++ b/app/src/main/java/com/radiola/service/SleepSoundPlayer.kt @@ -0,0 +1,120 @@ +package com.radiola.service + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.random.Random + +/** Тип звука для засыпания (процедурная генерация цветного шума). */ +enum class SleepSound(val key: String, val title: String) { + WHITE("white", "Белый шум"), + PINK("pink", "Розовый шум"), + BROWN("brown", "Коричневый шум"); + + companion object { + fun fromKey(key: String?): SleepSound? = entries.firstOrNull { it.key == key } + } +} + +/** + * Проигрыватель цветного шума для засыпания. Генерирует PCM на отдельном потоке и + * пишет в [AudioTrack] (streaming). Громкость регулируется на лету (для fade/кроссфейда). + * Розовый — фильтр Пола Келлета, коричневый — интегрированный белый (random walk). + */ +@Singleton +class SleepSoundPlayer @Inject constructor() { + + private val sampleRate = 44100 + private val bufSize = AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ).coerceAtLeast(4096) + + @Volatile private var track: AudioTrack? = null + @Volatile private var thread: Thread? = null + @Volatile private var running = false + + /** Запустить генерацию шума [sound]. Стартовая громкость 0 — нарастает кроссфейдом. */ + @Synchronized + fun start(sound: SleepSound) { + stop() + val at = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build(), + ) + .setBufferSizeInBytes(bufSize * 2) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + at.setVolume(0f) + at.play() + track = at + running = true + thread = Thread { generate(at, sound) }.apply { priority = Thread.MIN_PRIORITY; start() } + } + + /** Громкость 0..1 (для плавного появления/затухания). */ + fun setVolume(v: Float) { + track?.setVolume(v.coerceIn(0f, 1f)) + } + + @Synchronized + fun stop() { + running = false + thread?.let { runCatching { it.join(300) } } + thread = null + track?.let { t -> + runCatching { t.stop() } + runCatching { t.release() } + } + track = null + } + + private fun generate(at: AudioTrack, sound: SleepSound) { + val n = 2048 + val buf = ShortArray(n) + // Состояние фильтров розового шума (Пол Келлет) + var b0 = 0f; var b1 = 0f; var b2 = 0f; var b3 = 0f; var b4 = 0f; var b5 = 0f; var b6 = 0f + var lastBrown = 0f + while (running) { + for (i in 0 until n) { + val white = Random.nextFloat() * 2f - 1f + val sample = when (sound) { + SleepSound.WHITE -> white * 0.35f + SleepSound.PINK -> { + b0 = 0.99886f * b0 + white * 0.0555179f + b1 = 0.99332f * b1 + white * 0.0750759f + b2 = 0.96900f * b2 + white * 0.1538520f + b3 = 0.86650f * b3 + white * 0.3104856f + b4 = 0.55000f * b4 + white * 0.5329522f + b5 = -0.7616f * b5 - white * 0.0168980f + val pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362f + b6 = white * 0.115926f + pink * 0.11f + } + SleepSound.BROWN -> { + val brown = (lastBrown + 0.02f * white) / 1.02f + lastBrown = brown + brown * 3.5f + } + } + buf[i] = (sample.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort() + } + val written = at.write(buf, 0, n) + if (written < 0) break + } + } +} diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 83ab12d..155ee23 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -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(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) diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index 1d9e2ac..dbb6c7e 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -75,7 +75,8 @@ class PlayerViewModel @Inject constructor( // Таймер сна: оставшееся время в мс (null = выключен). val sleepRemainingMs: StateFlow = 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