diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c0af221..4f1ed84 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,9 +14,9 @@ android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" - android:icon="@drawable/ic_launcher" + android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@drawable/ic_launcher_round" + android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Radiola" android:usesCleartextTraffic="true" diff --git a/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt b/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt new file mode 100644 index 0000000..b9631d9 --- /dev/null +++ b/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt @@ -0,0 +1,131 @@ +package com.radiola.service + +import android.content.Context +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.radiola.domain.model.Recording +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Контроллер воспроизведения записей эфира (отдельный ExoPlayer, независимый от радио). + */ +@Singleton +class RecordingPlaybackController @Inject constructor( + @ApplicationContext private val context: Context, + private val playerController: PlayerController +) { + private val scope = CoroutineScope(Dispatchers.Main) + + private val _current = MutableStateFlow(null) + val current: StateFlow = _current + + private val _isPlaying = MutableStateFlow(false) + val isPlaying: StateFlow = _isPlaying + + private val _positionMs = MutableStateFlow(0L) + val positionMs: StateFlow = _positionMs + + private val _durationMs = MutableStateFlow(0L) + val durationMs: StateFlow = _durationMs + + private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context).build().apply { + addListener(object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + _isPlaying.value = playing + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + val dur = duration + _durationMs.value = if (dur == C.TIME_UNSET) 0L else dur + } + Player.STATE_ENDED -> { + _isPlaying.value = false + _positionMs.value = _durationMs.value + } + else -> Unit + } + } + }) + } + + private var positionPollingJob: Job? = null + + init { + // Цикл обновления позиции каждые 500мс + positionPollingJob = scope.launch { + while (isActive) { + if (exoPlayer.isPlaying) { + _positionMs.value = exoPlayer.currentPosition + val dur = exoPlayer.duration + if (dur != C.TIME_UNSET) { + _durationMs.value = dur + } + } + delay(500) + } + } + } + + /** + * Начать воспроизведение записи. Сначала останавливает радио. + */ + fun play(recording: Recording) { + // Останавливаем радиоплеер + playerController.pause() + playerController.stop() + + _current.value = recording + _positionMs.value = 0L + _durationMs.value = recording.duration ?: 0L + + val mediaItem = MediaItem.fromUri(android.net.Uri.fromFile(File(recording.filePath))) + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() + exoPlayer.play() + } + + /** Переключить паузу/воспроизведение. */ + fun togglePlayPause() { + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + exoPlayer.play() + } + } + + /** Перейти к позиции в мс. */ + fun seekTo(ms: Long) { + val target = ms.coerceIn(0L, _durationMs.value.coerceAtLeast(1L)) + exoPlayer.seekTo(target) + _positionMs.value = target + } + + /** Перемотать на deltaMs (может быть отрицательным). */ + fun seekBy(deltaMs: Long) { + seekTo(_positionMs.value + deltaMs) + } + + /** Остановить воспроизведение и сбросить текущую запись. */ + fun stop() { + exoPlayer.stop() + _current.value = null + _isPlaying.value = false + _positionMs.value = 0L + _durationMs.value = 0L + } +} diff --git a/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt new file mode 100644 index 0000000..64776fa --- /dev/null +++ b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt @@ -0,0 +1,214 @@ +package com.radiola.ui.recordings + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Pause +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.RotateCcw +import com.composables.icons.lucide.RotateCw +import com.radiola.domain.model.Recording +import com.radiola.ui.theme.Motion +import com.radiola.ui.theme.RadiolaTheme +import com.radiola.ui.theme.pressScale +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Контент нижнего листа (bottom sheet) для воспроизведения записи эфира. + * Оборачивать в [ModalBottomSheet] на стороне вызывающего. + */ +@Composable +fun RecordingPlayerSheet( + recording: Recording, + onDismiss: () -> Unit, + viewModel: RecordingPlayerViewModel +) { + val isPlaying by viewModel.isPlaying.collectAsState() + val positionMs by viewModel.positionMs.collectAsState() + val durationMs by viewModel.durationMs.collectAsState() + val haptic = LocalHapticFeedback.current + val colors = RadiolaTheme.colors + val dateFormat = remember { SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) } + + // Запуск воспроизведения при открытии листа + LaunchedEffect(recording) { + viewModel.play(recording) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(8.dp)) + + // Метка «ЗАПИСЬ ЭФИРА» + Text( + text = "ЗАПИСЬ ЭФИРА", + style = MaterialTheme.typography.labelSmall.copy( + letterSpacing = 2.sp, + fontWeight = FontWeight.SemiBold + ), + color = colors.accent + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Название станции + Text( + text = recording.stationName, + style = MaterialTheme.typography.headlineSmall, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Трек (если есть) + дата + val meta = buildString { + if (!recording.trackName.isNullOrBlank()) { + append(recording.trackName) + append(" · ") + } + append(dateFormat.format(Date(recording.startTime))) + } + Text( + text = meta, + style = MaterialTheme.typography.bodySmall, + color = colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Seekbar + val effectiveDuration = durationMs.coerceAtLeast(recording.duration ?: 1L).coerceAtLeast(1L) + Slider( + value = positionMs.toFloat(), + onValueChange = { viewModel.seekTo(it.toLong()) }, + valueRange = 0f..effectiveDuration.toFloat(), + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.surface2 + ) + ) + + // Время: текущее и общее + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatMs(positionMs), + style = MaterialTheme.typography.labelSmall, + color = colors.textMuted + ) + Text( + text = formatMs(effectiveDuration), + style = MaterialTheme.typography.labelSmall, + color = colors.textMuted + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Ряд управления: rewind15, play/pause, forward15 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + // Перемотка назад на 15 секунд + IconButton( + onClick = { viewModel.rewind15() }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Lucide.RotateCcw, + contentDescription = "Назад 15 сек", + tint = colors.textSecondary, + modifier = Modifier.size(24.dp) + ) + } + + // Кнопка play/pause + val playInteraction = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .pressScale(interactionSource = playInteraction), + contentAlignment = Alignment.Center + ) { + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.togglePlayPause() + }, + modifier = Modifier.fillMaxSize(), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent, + contentColor = colors.bgBase + ), + contentPadding = PaddingValues(0.dp), + interactionSource = playInteraction + ) { + Crossfade( + targetState = isPlaying, + animationSpec = tween(Motion.Fast), + label = "playPauseCrossfade" + ) { playing -> + Icon( + imageVector = if (playing) Lucide.Pause else Lucide.Play, + contentDescription = if (playing) "Пауза" else "Воспроизвести", + modifier = Modifier.size(28.dp) + ) + } + } + } + + // Перемотка вперёд на 15 секунд + IconButton( + onClick = { viewModel.forward15() }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Lucide.RotateCw, + contentDescription = "Вперёд 15 сек", + tint = colors.textSecondary, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +/** Форматирует миллисекунды в строку mm:ss. */ +private fun formatMs(ms: Long): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%02d:%02d".format(minutes, seconds) +} diff --git a/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerViewModel.kt b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerViewModel.kt new file mode 100644 index 0000000..9c8d952 --- /dev/null +++ b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerViewModel.kt @@ -0,0 +1,38 @@ +package com.radiola.ui.recordings + +import androidx.lifecycle.ViewModel +import com.radiola.domain.model.Recording +import com.radiola.service.RecordingPlaybackController +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +/** + * ViewModel экрана воспроизведения записи. Проксирует состояние и команды + * к [RecordingPlaybackController]. + */ +@HiltViewModel +class RecordingPlayerViewModel @Inject constructor( + private val controller: RecordingPlaybackController +) : ViewModel() { + + val current: StateFlow = controller.current + val isPlaying: StateFlow = controller.isPlaying + val positionMs: StateFlow = controller.positionMs + val durationMs: StateFlow = controller.durationMs + + fun play(recording: Recording) = controller.play(recording) + + fun togglePlayPause() = controller.togglePlayPause() + + fun seekTo(ms: Long) = controller.seekTo(ms) + + /** Перемотка назад на 15 секунд. */ + fun rewind15() = controller.seekBy(-15_000L) + + /** Перемотка вперёд на 15 секунд. */ + fun forward15() = controller.seekBy(15_000L) + + /** Закрыть плеер и остановить воспроизведение. */ + fun close() = controller.stop() +} diff --git a/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt b/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt index 9608266..16e3202 100644 --- a/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt +++ b/app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow @@ -40,15 +39,15 @@ import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun RecordingsScreen( viewModel: RecordingsViewModel = hiltViewModel() ) { val recordings by viewModel.recordings.collectAsState() val isRecording by viewModel.isRecording.collectAsState() - val context = LocalContext.current val colors = RadiolaTheme.colors + var playing by remember { mutableStateOf(null) } Column( modifier = Modifier @@ -119,21 +118,7 @@ fun RecordingsScreen( items(recordings, key = { it.id }) { recording -> RecordingItem( recording = recording, - onPlay = { - // TODO: воспроизвести запись через ExoPlayer или внешний плеер - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply { - setDataAndType( - androidx.core.content.FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - java.io.File(recording.filePath) - ), - "audio/*" - ) - addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(intent) - }, + onPlay = { playing = recording }, onDelete = { viewModel.deleteRecording(recording.id) }, modifier = Modifier.animateItemPlacement() ) @@ -142,6 +127,20 @@ fun RecordingsScreen( } } } + + // Встроенный плеер в нижнем листе + playing?.let { rec -> + ModalBottomSheet( + onDismissRequest = { playing = null }, + containerColor = colors.bgBase + ) { + RecordingPlayerSheet( + recording = rec, + onDismiss = { playing = null }, + viewModel = hiltViewModel() + ) + } + } } @Composable diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..c7ce710 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..0760702 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..0760702 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fb2f40a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..63bd0ea Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..bb39a2f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0a37e65 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6ed0381 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ