diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index cd253c9..54fe575 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -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(null) val icyTitle: StateFlow = _icyTitle.asStateFlow() + // ── Таймер сна ── + // Оставшееся время в мс (null = таймер выключен). В последние FADE_MS звук + // плавно затухает (экспоненциальная кривая), затем пауза. + private val _sleepRemainingMs = MutableStateFlow(null) + val sleepRemainingMs: StateFlow = _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() } 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 7c22e75..83ab12d 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -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) } /** Компактный чип текущего качества звука. */ 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 fa06577..1d9e2ac 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -72,6 +72,12 @@ class PlayerViewModel @Inject constructor( val isRecording: StateFlow = recordingRepository.isRecording + // Таймер сна: оставшееся время в мс (null = выключен). + val sleepRemainingMs: StateFlow = playerController.sleepRemainingMs + + fun startSleepTimer(minutes: Int) = playerController.startSleepTimer(minutes * 60_000L) + fun cancelSleepTimer() = playerController.cancelSleepTimer() + private var nowPlayingJob: Job? = null init {