Files
forkmessager/client-mobile/core/presentation/components/AppAudioPlayer.kt
Халимов Рустам 58fdf1aca1 Чат, вложения
2026-04-14 21:53:44 +03:00

311 lines
13 KiB
Kotlin

package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.VolumeUp
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun AppAudioPlayer(
url: String,
name: String? = null,
size: Long? = null,
isVoiceMessage: Boolean = false,
initialPlaybackSpeed: Float = core.utils.PlaybackManager.globalPlaybackSpeed.value,
autoPlay: Boolean = false,
onFinished: (Float) -> Unit = {},
modifier: Modifier = Modifier,
contentColor: Color = Color.White
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
// Update global speed when changed locally
LaunchedEffect(playbackSpeed) {
core.utils.PlaybackManager.globalPlaybackSpeed.value = playbackSpeed
}
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
playbackParameters = PlaybackParameters(playbackSpeed)
val savedPos = core.utils.PlaybackManager.getPosition(url)
seekTo(savedPos)
prepare()
}
}
// Обработка сигнала авто-запуска
LaunchedEffect(autoPlay) {
if (autoPlay) {
exoPlayer.play()
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
}
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(core.utils.PlaybackManager.getPosition(url)) }
var duration by remember { mutableLongStateOf(0L) }
var layoutSize by remember { mutableStateOf(IntSize.Zero) }
// Глобальная синхронизация: если играет кто-то другой, ставим паузу
val globalPlayingUrl by core.utils.PlaybackManager.currentPlayingUrl
LaunchedEffect(globalPlayingUrl) {
if (globalPlayingUrl != url && isPlaying) {
exoPlayer.pause()
}
}
val fileName = remember(url, name) {
name ?: url.substringAfterLast("/")
}
DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
if (playing) {
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = exoPlayer.duration
} else if (state == Player.STATE_ENDED) {
exoPlayer.pause() // Явно останавливаем, чтобы не было авто-реплея при seekTo
if (isVoiceMessage) {
exoPlayer.seekTo(0)
currentPosition = 0
core.utils.PlaybackManager.resetPosition(url)
}
onFinished(playbackSpeed)
}
}
}
exoPlayer.addListener(listener)
onDispose {
core.utils.PlaybackManager.savePosition(url, exoPlayer.currentPosition)
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
LaunchedEffect(isPlaying) {
if (isPlaying) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
core.utils.PlaybackManager.savePosition(url, currentPosition)
delay(200)
}
}
}
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f))
.padding(8.dp)
) {
// Header: Icon and Name
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
imageVector = Icons.Default.VolumeUp,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isVoiceMessage) "Голосовое сообщение" else fileName,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Play/Pause Button
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color.White)
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color(0xFF3390EC),
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Waveform
Box(
modifier = Modifier
.fillMaxWidth()
.height(30.dp)
.onSizeChanged { layoutSize = it }
.pointerInput(duration) {
detectTapGestures { offset ->
if (duration > 0 && layoutSize.width > 0) {
val pct = offset.x / layoutSize.width.toFloat()
val newPos = (pct * duration).toLong()
exoPlayer.seekTo(newPos)
currentPosition = newPos
}
}
},
contentAlignment = Alignment.CenterStart
) {
androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) {
val barCount = 40
val barSpacing = 2.dp.toPx()
val barWidth = (this.size.width - (barCount - 1) * barSpacing) / barCount
val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f
val barHeights = listOf(
0.3f, 0.5f, 0.2f, 0.7f, 0.4f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f,
0.2f, 0.6f, 0.5f, 0.8f, 0.3f, 0.7f, 0.4f, 0.6f, 0.2f, 0.8f,
0.4f, 0.5f, 0.3f, 0.7f, 0.5f, 0.6f, 0.4f, 0.3f, 0.4f, 0.6f,
0.3f, 0.2f, 0.4f, 0.5f, 0.3f, 0.6f, 0.4f, 0.7f, 0.3f, 0.5f
)
for (i in 0 until barCount) {
val x = i * (barWidth + barSpacing)
val heightPct = barHeights[i % barHeights.size]
val barHeight = this.size.height * heightPct
val isActive = (i.toFloat() / barCount) <= progress
drawRoundRect(
color = if (isActive) Color.White else Color.White.copy(alpha = 0.25f),
topLeft = androidx.compose.ui.geometry.Offset(x, (this.size.height - barHeight) / 2),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx())
)
}
}
}
Spacer(modifier = Modifier.height(2.dp))
// Footer: Time, Size, Download
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = formatDuration(if (isPlaying || currentPosition > 0) currentPosition else duration),
fontSize = 11.sp,
color = contentColor.copy(alpha = 0.7f)
)
if (!isVoiceMessage) {
size?.let {
Spacer(modifier = Modifier.width(12.dp))
val sizeStr = if (it > 1024 * 1024) String.format("%.1f MB", it / (1024.0 * 1024.0)) else "${it / 1024} KB"
Text(
text = sizeStr,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = contentColor.copy(alpha = 0.7f)
)
}
}
}
if (!isVoiceMessage) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).clickable {
scope.launch {
core.utils.DownloadUtils.downloadMedia(context, url, fileName)
}
}
)
}
}
}
// Speed toggle (only for voice messages or if user wants)
if (isVoiceMessage || playbackSpeed > 1.0f) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
onClick = {
playbackSpeed = when (playbackSpeed) {
1.0f -> 1.5f
1.5f -> 2.0f
else -> 1.0f
}
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
},
color = Color.White.copy(alpha = 0.1f),
shape = CircleShape,
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "${if (playbackSpeed % 1.0f == 0.0f) playbackSpeed.toInt() else playbackSpeed}x",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
}
}
}
private fun formatDuration(durationMs: Long): String {
if (durationMs <= 0) return "0:00"
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%d:%02d", minutes, seconds)
}