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()
}

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

View File

@@ -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 {