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.audio.TeeAudioProcessor
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.extractor.metadata.icy.IcyInfo import androidx.media3.extractor.metadata.icy.IcyInfo
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext 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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -67,6 +74,18 @@ class PlayerController @Inject constructor(
private val _icyTitle = MutableStateFlow<String?>(null) private val _icyTitle = MutableStateFlow<String?>(null)
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow() 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 private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
var onSkipToNext: (() -> Unit)? = null var onSkipToNext: (() -> Unit)? = null
@@ -227,6 +246,43 @@ class PlayerController @Inject constructor(
exoPlayer.replaceMediaItem(0, updatedMediaItem) 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() { fun pause() {
exoPlayer.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.Lucide
import com.composables.icons.lucide.Mic import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.MicOff import com.composables.icons.lucide.MicOff
import com.composables.icons.lucide.Moon
import com.composables.icons.lucide.Music import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play import com.composables.icons.lucide.Play
@@ -82,7 +83,9 @@ fun PlayerBottomSheet(
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) } var showLyrics by remember { mutableStateOf(false) }
var showQuality by remember { mutableStateOf(false) } var showQuality by remember { mutableStateOf(false) }
var showSleep by remember { mutableStateOf(false) }
val currentQuality by viewModel.currentQuality.collectAsState() val currentQuality by viewModel.currentQuality.collectAsState()
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
val spectrum by viewModel.spectrum.collectAsState() val spectrum by viewModel.spectrum.collectAsState()
val vizStyle by viewModel.visualizerStyle.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) { if (landscape) {
// Альбом: слева обложка с названием станции, справа — трек, эквалайзер, // Альбом: слева обложка с названием станции, справа — трек, эквалайзер,
// управление и сервисы (правая панель скроллится на низких экранах). // управление и сервисы (правая панель скроллится на низких экранах).
@@ -379,8 +413,10 @@ fun PlayerBottomSheet(
controlsSection() controlsSection()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
servicesSection() servicesSection()
if (track != null) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
sleepSection()
if (track != null) {
Spacer(Modifier.height(10.dp))
lyricsSection() lyricsSection()
} }
} }
@@ -411,6 +447,8 @@ fun PlayerBottomSheet(
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
servicesSection() servicesSection()
if (enabledServices.isNotEmpty()) Spacer(Modifier.height(12.dp)) if (enabledServices.isNotEmpty()) Spacer(Modifier.height(12.dp))
sleepSection()
Spacer(Modifier.height(10.dp))
lyricsSection() 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 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 private var nowPlayingJob: Job? = null
init { init {