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,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