feat: фирменная иконка приложения + внутренний плеер записей
- адаптивная иконка лаунчера: градиентный фон (C2F25B->6FA53C) + монограмма R (foreground + monochrome для тем Android 13), манифест -> @mipmap - воспроизведение своих записей ВНУТРИ приложения вместо внешнего плеера: RecordingPlaybackController (отдельный ExoPlayer, останавливает радио), RecordingPlayerSheet с перемоткой (Slider), play/pause, +/-15с, таймеры
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user