From e19044457738a2f3d2d408ec92b0a9821234b382 Mon Sep 17 00:00:00 2001 From: nk Date: Wed, 3 Jun 2026 00:13:12 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=84=D0=B8=D1=80=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?+=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B5=D0=BD=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BB=D0=B5=D0=B5=D1=80=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - адаптивная иконка лаунчера: градиентный фон (C2F25B->6FA53C) + монограмма R (foreground + monochrome для тем Android 13), манифест -> @mipmap - воспроизведение своих записей ВНУТРИ приложения вместо внешнего плеера: RecordingPlaybackController (отдельный ExoPlayer, останавливает радио), RecordingPlayerSheet с перемоткой (Slider), play/pause, +/-15с, таймеры --- app/src/main/AndroidManifest.xml | 4 +- .../service/RecordingPlaybackController.kt | 131 +++++++++++ .../ui/recordings/RecordingPlayerSheet.kt | 214 ++++++++++++++++++ .../ui/recordings/RecordingPlayerViewModel.kt | 38 ++++ .../radiola/ui/recordings/RecordingsScreen.kt | 35 ++- .../res/drawable/ic_launcher_background.xml | 18 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 1160 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 725 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 1570 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 2352 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 3235 bytes 13 files changed, 432 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/radiola/service/RecordingPlaybackController.kt create mode 100644 app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt create mode 100644 app/src/main/java/com/radiola/ui/recordings/RecordingPlayerViewModel.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png 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 0000000000000000000000000000000000000000..fb2f40ab08b3e95804757cb21906314b65ab047d GIT binary patch literal 1160 zcmeAS@N?(olHy`uVBq!ia0vp^i$Iuz4M-mPBqj}{7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`0h7I;J!GcfQS24TkI`72U@YGgfK978JN-rij}S;kd_{lSSVZ-Xv}8%H0J z6*@V;!NpB!qKAqRpJb;^Cx|`q#0iflCj;`!^aEik!fm(Dg>b5)^j z_1$+z|HS>A_^*HEE?cQONMPq|%Z9>R-QB^wCcz z1df~ysayBke%YH-o+_>a;&F==)HZ#w|Ebw5k=<1K|9FT`$*XM{A(IsJs@%4?8%})I zTv7bF=H(j8IN57wFRr-OyhFBqdg*=ls}b*%j+9oLta_>N?!S~?^;z?2=B33ZYo02c z+g&L4^7o2OZ8N;P9ecK2Z{2X`Cx`vauqFxN?io+>mKQ~TFD<&sH0}H)g<~J(<{XK=1E`E7Gq|GG-1WV5@y-7Fv9H(Ok{)%SbKAHCnIaHT@q z<}*uj8DAe(ZI(E<@x@!2BYZJd9nU2eC5QK&cb~kwMBwm4<4f$db0XSaoQdK{il};Y z-Tw4K@6N44N4D-)C|FURcF_~a7U;fGYQOwinwI_5x04*a9eHkV{1f!OH0Z3l?T??E zOQN_RS+>2X$PV7_{Wyv{$>O*`?&%04T{gotvWvd&PkUc{*(8b=q=cjRu3m4JX~#nu z=PlRkJ-5gl*-+N8ZQBf&*N!$oO}U1R*P127{vSD78yTGw-=@&Eb-s8+bWY4Pg=1#x zw>odxdbBN-GpR$epjLMI6cC3iDZ<(%I+_JXHiZc{eEY-7t!h(tYQlv_ATdu@KbLh* G2~7ZbydmHK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..63bd0ea3bb8c3a4c7508076762aaee0fadf63b0c GIT binary patch literal 725 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