feat: фирменная иконка приложения + внутренний плеер записей

- адаптивная иконка лаунчера: градиентный фон (C2F25B->6FA53C) + монограмма R
  (foreground + monochrome для тем Android 13), манифест -> @mipmap
- воспроизведение своих записей ВНУТРИ приложения вместо внешнего плеера:
  RecordingPlaybackController (отдельный ExoPlayer, останавливает радио),
  RecordingPlayerSheet с перемоткой (Slider), play/pause, +/-15с, таймеры
This commit is contained in:
nk
2026-06-03 00:13:12 +03:00
parent d0e5f4e8c5
commit e190444577
13 changed files with 432 additions and 20 deletions

View File

@@ -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<Recording?>(null)
val current: StateFlow<Recording?> = _current
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying
private val _positionMs = MutableStateFlow(0L)
val positionMs: StateFlow<Long> = _positionMs
private val _durationMs = MutableStateFlow(0L)
val durationMs: StateFlow<Long> = _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
}
}

View File

@@ -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)
}

View File

@@ -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<Recording?> = controller.current
val isPlaying: StateFlow<Boolean> = controller.isPlaying
val positionMs: StateFlow<Long> = controller.positionMs
val durationMs: StateFlow<Long> = 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()
}

View File

@@ -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<Recording?>(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