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:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Компактный чип текущего качества звука. */
|
/** Компактный чип текущего качества звука. */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user