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

@@ -23,10 +23,17 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.extractor.metadata.icy.IcyInfo
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@@ -67,6 +74,18 @@ class PlayerController @Inject constructor(
private val _icyTitle = MutableStateFlow<String?>(null)
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow()
// ── Таймер сна ──
// Оставшееся время в мс (null = таймер выключен). В последние FADE_MS звук
// плавно затухает (экспоненциальная кривая), затем пауза.
private val _sleepRemainingMs = MutableStateFlow<Long?>(null)
val sleepRemainingMs: StateFlow<Long?> = _sleepRemainingMs.asStateFlow()
private val timerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var sleepJob: Job? = null
private companion object {
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
}
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
var onSkipToNext: (() -> Unit)? = null
@@ -227,6 +246,43 @@ class PlayerController @Inject constructor(
exoPlayer.replaceMediaItem(0, updatedMediaItem)
}
/**
* Запустить таймер сна на [durationMs] мс. В последние FADE_MS звук плавно
* затухает (экспоненциально, чтобы спад воспринимался естественно), затем пауза.
*/
fun startSleepTimer(durationMs: Long) {
sleepJob?.cancel()
exoPlayer.volume = 1f
sleepJob = timerScope.launch {
val end = SystemClock.elapsedRealtime() + durationMs
while (true) {
val remaining = end - SystemClock.elapsedRealtime()
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)
delay(150)
} else {
delay(1_000)
}
}
exoPlayer.pause()
exoPlayer.volume = 1f
_sleepRemainingMs.value = null
sleepJob = null
}
}
/** Отменить таймер сна и вернуть полную громкость. */
fun cancelSleepTimer() {
sleepJob?.cancel()
sleepJob = null
exoPlayer.volume = 1f
_sleepRemainingMs.value = null
}
fun pause() {
exoPlayer.pause()
}